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/*).
Conventions
Section titled “Conventions”-
Versioning.
/v1prefix, additive-only. A breaking change gets a new versioned URL; an existing one is never mutated under you. -
Caching. Every artifact response carries
ETagandLast-Modified(the artifact’sgeneratedAt) and honoursIf-None-Matchwith304. 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.
Route set at v0
Section titled “Route set at v0”| Route | Method | Notes |
|---|---|---|
/badge/{node_id}.json | GET | Shields endpoint schema |
/v1/verify/{certId} | GET | Certificate status record, including the full signed token |
/certs/policy/latest.json | GET | The certificate policy: every class, its variants, and the claims each permits |
/jwks.json | GET | Public key set (production) |
/stats.json | GET | Public counters |
/v1/meta | GET | The deployment’s self-description |
/v1/forms/{contact|abuse|dsar} | POST | Challenge-protected intake; stores nothing |
/v1/registry/*, /v1/waivers/*, /v1/entitlements/{co}.jws, /v1/ledger/*, /v1/allocation-keys/*, /ct/* | GET | Byte-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.
GET /badge/{node_id}.json
Section titled “GET /badge/{node_id}.json”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.
GET /v1/verify/{certId}
Section titled “GET /v1/verify/{certId}”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.
GET /certs/policy/latest.json
Section titled “GET /certs/policy/latest.json”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.
GET /jwks.json
Section titled “GET /jwks.json”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.
GET /stats.json
Section titled “GET /stats.json”{ "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.
GET /v1/meta
Section titled “GET /v1/meta”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.
POST /v1/forms/{contact|abuse|dsar}
Section titled “POST /v1/forms/{contact|abuse|dsar}”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.
Coverage answers
Section titled “Coverage answers”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.
Rate limits
Section titled “Rate limits”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.
| Class | Routes | Limit per address |
|---|---|---|
badge | /badge/{node_id}.json | 600 requests per minute |
verify | /v1/verify/{certId} | 30 requests per minute |
hot | /jwks.json, /stats.json, /v1/meta | 120 requests per minute |
artifacts | the artifact passthrough | 60 requests per minute |
forms | /v1/forms/* | 5 requests per minute |
initiation | the 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/orgs | 10 requests per minute |
proxied | the rest of the authenticated API — every other request on a /v1 path not on this page | 120 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.
The sandbox
Section titled “The sandbox”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 is400 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": truein the body and anX-PSN-Sandbox: 1header. 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.