Skip to content

Public API reference

Every route below is unauthenticated, keyless, CORS-open for GET, rate-limited per IP, and served from published artifacts. There is no database behind any of them: the read plane is a set of static documents on a content delivery network, which is why a hundredfold traffic spike is a cache statistic rather than an incident.

Base origin: https://api.purposesource.org. Machine endpoints are also mounted on the apex (/jwks.json, /stats.json, /ct/*).

  • Versioning. /v1 prefix, additive-only. A breaking change gets a new versioned URL; an existing one is never mutated under you.

  • Caching. Every artifact response carries ETag and Last-Modified (the artifact’s generatedAt) and honours If-None-Match with 304. Poll with conditional requests — it is the cheapest thing you can do and it is what these routes are designed for.

  • Freshness. Eventually consistent by design. The published propagation bound for coverage-relevant state is five minutes worldwide. No endpoint promises real-time.

  • Errors. One envelope, always:

    { "error": { "code": "cert_not_found", "message": "…", "traceId": "…" } }

    Codes are stable, additive-only, and lowercase snake case: invalid_repo_id, invalid_company_ref, repo_not_registered, company_ambiguous, cert_not_found, cert_not_in_ct_log, cert_revoked, artifact_not_found, sandbox_artifact_on_prod, turnstile_failed, rate_limited, artifacts_unavailable, internal.

RouteMethodNotes
/badge/{node_id}.jsonGETShields endpoint schema
/v1/verify/{certId}GETCertificate status record, including the full signed token
/certs/policy/latest.jsonGETThe certificate policy: every class, its variants, and the claims each permits
/jwks.jsonGETPublic key set (production)
/stats.jsonGETPublic counters
/v1/metaGETThe deployment’s self-description
/v1/forms/{contact|abuse|dsar}POSTChallenge-protected intake; stores nothing
/v1/registry/*, /v1/waivers/*, /v1/entitlements/{co}.jws, /v1/ledger/*, /v1/allocation-keys/*, /ct/*GETByte-exact passthrough of committed artifacts

Not switched on in production: the computed coverage endpoint and the change feed, for different reasons. /v1/coverage is built and switched on in the edge’s preview and dev configurations, over a published set of sample documents; production’s flag stays down until launch, when the artifact store arrives with it — a flag, not a rebuild. /v1/changes is specified and not built. Until coverage activates here, the proof of coverage is the signed entitlement record plus the verification page, and neither route changes schema when it activates.

Ask a host which of the two it serves rather than assuming: /v1/meta publishes routes as a map, so "coverage": false is that deployment telling you the route exists and it does not answer it.

Everything else under /v1 belongs to the signed-in application

Section titled “Everything else under /v1 belongs to the signed-in application”

One hostname carries two families, split by path. The table above is the public one: a path in it is answered from a published artifact, by the edge, to anybody. Every other /v1 path — sign-in, claims, an account, an organisation, a dashboard — belongs to the application API behind GitHub sign-in, and the edge only forwards it: never cached, cookies untouched, and answered by the application rather than by the read plane. There is nothing to document here for it, because it is not a public API and not part of this contract; the endpoints it serves are the ones the signed-in application calls, and they can change with the application.

Two consequences worth knowing if you build against this host. A path you invent under /v1 is not a 404 from the read plane — it is a path in the other family, so it is answered by whatever that family answers, and it is metered by the proxied class below rather than the artifacts one. And on a deployment where that family is not switched on, it is 404 route_not_enabled: /v1/meta reports which state you are talking to, as "api": "proxied" or "api": "disabled" — the two a healthy deployment reports. A third value, "api": "unconfigured", is a deployment that has the family switched on with no application to forward to; it refuses the family, and it is published rather than hidden because a deployment reporting proxied when it cannot proxy would be worse.

Shields.io endpoint schema, so a repository readme can embed it directly:

{
"schemaVersion": 1,
"label": "purpose source",
"message": "registered",
"color": "brightgreen",
"cacheSeconds": 3600
}

An unknown repository answers 200 with {"isError": true, …, "message": "not registered"} — shields renders errors from bodies, not from status codes, so a 404 would render as a broken image instead of an honest label.

A repository that is delisted, suspended, or has quit answers the neutral form ("message": "status: see registry", grey). A stale badge never keeps asserting registration: the worker cross-checks the delist set on every request, so the neutral form wins even against a cached badge artifact.

Cache: s-maxage=3600, stale-while-revalidate=86400, stale-if-error=604800.

Passthrough of the certificate record:

{
"schemaVersion": 1,
"generatedAt": "2027-03-02T10:00:00Z",
"cid": "cert_01jf8w2c9km3q7xz5r0v4t6y8a",
"status": "revoked",
"typ": "supporter",
"variant": "entitlement",
"sub": "Example Industries AG",
"scope": { "kind": "project", "repos": ["R_kgDOAbc123"] },
"band": "10-100M",
"period": { "validFrom": "2027-01-01", "validUntil": "2027-12-31" },
"issuedAt": "2027-01-01T09:00:00Z",
"kid": "psn-prod-2027-1",
"ct": { "seq": 118234, "segment": 11 },
"revocation": { "reasonClass": "project-delisting", "at": "2027-03-01T12:00:00Z" },
"supersedes": "cert_01he…",
"supersededBy": null,
"jws": "eyJhbGciOiJFUzI1NiIs…"
}

status is one of valid, expired, revoked, superseded. ct is null for a signed-but-unlogged certificate, and a client that sees null must render it as unverified. The record never contains more subject data than the certificate itself displays.

Verify it yourself. The record is not the proof; the signature is. Fetch /jwks.json, select the key by kid, and verify the jws locally — that is exactly what the verification page does in your browser, with no server in the trust path.

This endpoint currently serves the earlier certificate-policy format. The example below documents that historical schema, not the Entitlement terms. It is retained for readers of existing artifacts. The pricing page issues no certificates under this schema.

The proposed certificate policy and brand-use permission allow purchasers to use their own wording. Production issuance needs a new policy and certificate format before that proposal can go live; existing signed artifacts must retain their original bytes.

Earlier policy format — reference
{
"schemaVersion": 1,
"generatedAt": "2026-09-07T00:00:00Z",
"policyVersion": 1,
"phase": "P-M2",
"binding": "public claims about this certificate are governed by the enclosed claim-language kit",
"claimKitVersion": null,
"verify": "verify only at purposesource.org/verify",
"classes": [
{
"typ": "supporter",
"name": "payment certificate",
"kitVariant": "supporter",
"attests": "…",
"variants": [{ "variant": "entitlement", "label": "…", "issuable": true, "precondition": "…" }],
"permitted": ["…"],
"prohibited": ["…"],
"rules": ["COM-030", "MKT-033"]
}
],
"notIssuable": [
{ "class": "contributor participation certificate", "typ": "contributor", "variants": ["participation"], "notBefore": "…", "why": "…", "rules": ["CERT-005"] }
]
}

classes[] is the issuable set at phase: a class that cannot be issued appears in notIssuable[] with what has to exist first, never inside classes[] behind a flag. The typ tokens are the frozen enum — switch on the token, not on name. Schema: certificate-policy.v1.json.

Cache: s-maxage=3600. The policy is not data about the world and does not move with the registry or the ledger; it changes when policyVersion changes.

The production key set. Retired keys stay published with their validity windows, because rotation must never invalidate a certificate that was validly issued. The sandbox key set is a disjoint document at a separate path, and a sandbox key id (psn-sandbox-…) presented on a production route is an error, never a downgrade.

{
"schemaVersion": 1,
"generatedAt": "2026-09-01T05:00:00Z",
"state": "pre-launch",
"projectsRegistered": 0,
"contributorsClaimed": null,
"companiesCovered": 0,
"chfRoutedMinor": null,
"cur": "CHF",
"firstDisbursementScheduledFor": null,
"loi": null,
"smallnessThresholds": { "projects": 25, "contributors": 50, "companies": 10 }
}

state is one of pre-launch, launched-pre-disbursement, post-first-franc, computed from ledger and registry facts — never set by hand. A null numeric field means “not measurable yet” and must not be rendered as zero. Money is integer minor units plus a currency code.

The deployment’s self-description, used by the specification cross-check and by status probes:

{
"edgeVersion": "…",
"routes": { "badge": true, "verify": true, "jwks": true, "stats": true, "meta": true,
"forms": true, "artifacts": true, "coverage": false, "changes": false,
"api": false },
"coverage": { "algoVersion": "cov-v2", "moduleSha256": "…64 hex characters…" },
"contracts": { "answerEnum": ["…the eight coverage values…"], "schemaVersions": {} },
"rateLimits": {
"keyedBy": "ip: IPv4 address, IPv6 /64 prefix",
"enforced": true,
"scope": "per Cloudflare location",
"classes": {
"badge": { "limit": 600, "periodSeconds": 60, "routes": ["/badge/{node_id}.json"] }
}
}
}

routes is every route name this deployment knows, each with its flag. A false is a statement and not a silence: the route exists on this build and this host does not serve it.

coverage.moduleSha256 is the SHA-256 of the coverage function the deployment carries. Hash the file coverage.algoVersion names in the specification repository’s coverage/ directory (cov-v2.ts today) yourself and compare: equal digests mean the code that answered you is the code that was published. The digest is served whether or not the route is switched on, because the question it settles is what the code would answer.

rateLimits is the table below, in machine form: one entry per class, with the routes it meters. enforced is false on a deployment whose limiter is switched off, so the figures are never advertised by a host that is not applying them.

Challenge-protected intake. The worker verifies the challenge token server-side, forwards the payload as one transactional email, and stores nothing. Response: 202 with { "ack": true, "responseTarget": "5 business days" }. A failed challenge is 403 turnstile_failed. Rate limit: at most about five per minute per address, on top of the challenge — a ceiling the platform approaches rather than an exact count, for the reason given under Rate limits below.

Organisation names are sent by POST, never in a query string, so a third party’s name never appears in a shareable or cached URL.

The coverage answer set is closed at exactly eight values, and it is worth reading before you build against it — the enumeration is frozen, and a client that switch-cases over these eight will not need changing when the endpoint activates here:

yes-via-pass, yes-via-project, yes-via-portfolio, yes-via-waiver, yes-via-donation, no, lapsed-in-grace, no-entitlement-required-under-threshold.

yes-via-donation is retired and never returned: the direct-donation route it answered for was dropped before it opened. It stays in the set because the set is frozen.

Semantics, precedence, and the boundary cases are on the coverage page.

Every route is limited per client address. The class names below are the ones /v1/meta publishes under rateLimits, so a client can read the figures instead of discovering them by being refused.

An IPv4 address is counted as it is. An IPv6 address is counted by its /64 prefix — the block every IPv6 link is given, inside which a host changes its own address as a matter of course — so every address of one /64 spends one allowance, and a new address in the same /64 does not bring a new one. /v1/meta says so in rateLimits.keyedBy.

ClassRoutesLimit per address
badge/badge/{node_id}.json600 requests per minute
verify/v1/verify/{certId}30 requests per minute
hot/jwks.json, /stats.json, /v1/meta120 requests per minute
artifactsthe artifact passthrough60 requests per minute
forms/v1/forms/*5 requests per minute
initiationthe authenticated API’s sign-in and claim starts: GET /v1/auth/github/start, GET /v1/auth/github/callback, POST /v1/auth/magic-link, GET /v1/auth/magic-link/verify, POST /v1/auth/magic-link/verify, POST /v1/auth/email-code, POST /v1/auth/email-code/verify, POST /v1/auth/step-up/email, POST /v1/account/addresses, POST /v1/claims, POST /v1/wizard/runs, POST /v1/orgs10 requests per minute
proxiedthe rest of the authenticated API — every other request on a /v1 path not on this page120 requests per minute

Read every figure as a ceiling, not a counter. Each class has a counter of its own, so badge traffic never spends the forms allowance — but all seven are the same kind of rate-limiting binding, and it is permissive and eventually consistent: a short burst can be admitted well past the nominal count before the 429s begin, and once they begin they interleave with ordinary responses rather than replacing them. So each row means at most about that many requests per minute per address, per Cloudflare location — enough to size a well-behaved client against, not a quota you can meter yourself by.

The artifacts class covers every passthrough route in the table at the top of this page — registry, waivers, entitlements, ledger, allocation keys, transparency log — and any path that matches none of them. /v1/coverage is metered in that same artifacts class, and /v1/changes in the hot class — before the route flag is read, so a host that does not serve one of them still spends your allowance refusing it, and a flag flip changes no figure you were given. The initiation and proxied classes are metered on the same terms — whether or not the deployment you are talking to forwards that family at all.

The two authenticated classes are split by what a request costs. initiation is the twelve requests that start something on your behalf — an OAuth round trip to GitHub, a magic-link email and its confirmation, a sign-in code and its check, a step-up confirmation email, a code to confirm an address you are adding, a claim, an adoption run, an organisation’s registration — and the method is part of the key: POST /v1/wizard/runs starts a run and is initiation; GET /v1/wizard/runs lists them and is proxied. Everything else on that family — the session document, every list and detail read, every panel, every change to a session you already hold, and every preflight — is proxied, at a figure sized for a dashboard: a page load is a handful of requests, a tab regaining focus repeats most of them, and an office behind one address shares one counter per location. Until 2026-09-21 the ten applied to the whole family, and a single sign-in-and-adopt sequence could spend it on reads; the split is what changed, and the ten still bounds what reaches GitHub — in every spelling of the path. The class is decided on the path the application routes on, built in this order: %2F, %5C and a backslash read as a slash; . and .. segments resolved while empty segments are still in place, so a .. straight after // removes the empty segment rather than the one before it; then a run of slashes read as one; then percent-decoded once and lower-cased. So GET /v1/Auth/GitHub/Start, GET /v1/auth/github/%73tart, GET /v1//auth/github/start and GET /v1/auth%2Fgithub%2Fstart are all initiation, not proxied, and the eleventh start from one address is shed however it is written. The request is forwarded on that same routed path, with the case and every other escape of each segment exactly as you wrote them; a path that routes outside /v1, or onto one of the public routes on this page, is not forwarded at all.

Two requests that also start something — POST /v1/checkout/sessions and POST /v1/orgs/{coId}/entitlements/checkout, each of which records a checkout session and creates a payment-rail transaction — are proxied, not initiation, and that is a recorded decision rather than an oversight. The initiation set is the record’s own list of sign-in and claim starts, and unlike an OAuth start or a magic-link request, which are anonymous and have no per-account ceiling anywhere but this one, a checkout needs a signed-in session, so the application’s own per-session limiter (sixty requests a minute per session, per application replica) bounds it as well. One signed-in account is therefore held to a hundred and twenty a minute per address here and sixty a minute per session there. If the record ever moves those two into the ten, the initiation rows in /v1/meta will say so.

A badge embed will not hit its limit. A readme badge fans out to every viewer of the repository, and every one of those requests is absorbed by the cache long before it reaches the worker — which is why the badge figure is six hundred rather than sixty. It is an abuse backstop, not a traffic budget: no popular project should ever see a 429 from its own readme.

The counters are held at the Cloudflare location serving you, so these are per-location backstops rather than a global quota. A HEAD is counted with the GET it stands in for.

A preflight OPTIONS is never counted on the routes of this page, because those are answered at the edge and cost nothing to serve. On the authenticated family it is counted, like every other method: that family is forwarded to the application, so a preflight there is a request the application has to answer, and a figure that excluded it would not be the ceiling it claims to be. It is counted in proxied, never in initiation, because a preflight starts nothing — the application answers it before any handler runs. Two practical notes if you are building the client: a plain GET with credentials and no custom header needs no preflight at all, and the application caches a preflight for ten minutes, so a mutation spends one of initiation or proxied as its row says and, once in ten minutes, one more of proxied.

A 429 carries the standard error envelope and a Retry-After header. Retry-After is the full window rather than a computed remainder — the limiter reports only whether you are over, so the full window is the only value we can promise is long enough. Poll with If-None-Match: a 304 is cheap for both of us and is what these routes are shaped for. On the authenticated family a 429 — and any other refusal the edge itself answers rather than relays, such as a 503 service_degraded — is readable from the dashboard’s own origin: it carries that one origin in access-control-allow-origin with credentials and exposes Retry-After, where every other origin sees the public *. Before 2026-09-21 it carried * for everyone, which a browser refuses on a credentialed request, so the dashboard saw a network error instead of the envelope.

Form intake is rate-limited before the challenge is checked, so a burst from one address is answered 429 without its token being verified at all. The challenge is the part that fails closed: the limiter admits a request it cannot meter, while a submission whose challenge cannot be verified is refused.

A sandbox tenant is specified and not yet deployed. When it activates it will be a disjoint tenant, not a flag on these routes, because the failure mode being designed out is a test artifact being mistaken for a production credential:

  • Separate paths: /sandbox/v1/coverage, /sandbox/v1/verify/{certId}, /sandbox/v1/registry/*, and /sandbox/jwks.json.
  • Identical schemas and the identical coverage enumeration, so a client tested against the sandbox needs no change in production.
  • Disjoint key sets. Sandbox key ids are psn-sandbox-…; no route ever merges the two key sets, and a sandbox key id presented on a production route is 400 sandbox_artifact_on_prod — an error, never a downgrade.
  • Sandbox signatures never enter the transparency log, deliberately. That is what makes a test artifact detectable as one.
  • Every sandbox response is marked: top-level "sandbox": true in the body and an X-PSN-Sandbox: 1 header. The verification page banners a sandbox certificate as a test artifact before it says anything else about it.

Until the tenant exists, there is nothing to point a test suite at, and this page says so rather than publishing a base URL that answers nothing.