Skip to main content

One call, two environment variables

POST /api/v1/developers/orgs/{orgId}/apps is the onboarding path. It takes three things you already know — a name, whether you are a platform or a provider, and your callback URL — and runs the entire ceremony server-side:
1

identity

Generates an Ed25519 keypair and self-registers a DID derived from your app name.
2

binding

Runs the real claim ceremony with that key, binding the DID to your org.
3

trust_list

Recompiles the census Trust List and requires the entry to actually resolve before anything is minted.
4

client_registration

Publishes your Link client registration (providers only — platforms skip this).
5

credential

Mints the PHOSRA_CREDENTIAL from the same key generated in step 1.
You never see a nonce, a key id, an entry_role, or a signing operation. You copy two values into your deployment and you are done.
One honest caveat up front, so the rest of this page reads straight. A brand-new app stops one step short of a credential: self-registration produces a provisional Trust List entry, and credential issuance requires an accredited one. So your first response will normally say status: "pending_accreditation" — everything except the credential exists and works. Once the entry is accredited you re-POST the identical request and the credential is issued on that replay. Nothing is re-created and nothing is lost in between. See the status field, honestly.

Why this exists: the failure it retires

Before this endpoint, the six steps above were run by hand, and the keypair in your credential and the public key the census published for your DID were two independent pieces of data with nothing structurally binding them together. When they drifted, every /api/phosra/* route returned an opaque 503 PHOSRA_NOT_READY, identical whether the credential was missing, the standing had lapsed, or the key had diverged. No SDK upgrade could fix it, because both sides of the failing comparison were data, not code. The funnel closes that hole by construction: one keypair is generated once and used for both halves. They cannot disagree, because there is only one of them.
Proven, not asserted. In the verification run below, the census returned writer_seed: 6Yb5v8NanEf7rl5ng7HjcpzyJpbLFal5cSYqdruKxn8 and published x: UEm-psHzpBJWPxYg-E0wBeNxULGYa9x51knJgy3-n14 under that DID’s key_id. Deriving the Ed25519 public half of the seed yields exactly that x. The seed you receive is the key the census publishes.

Where this runs today

Verified live on the Phosra-internal staging sandbox census only (https://phosra-api-sandbox-staging.up.railway.app), last exercised end-to-end 2026-07-27. Every request and response on this page is real output from that host.It is not reachable on the other censuses as of 2026-07-27:A 401 on the production census is not evidence the route exists. Its /developers/* subtree runs its auth middleware before route matching, so every unmatched path under that prefix returns the identical 401. The route’s presence there was confirmed a different way: the commit production is currently running contains no /apps route in its router and no handler file for it.Do not build a production launch plan around this endpoint yet. It is documented now so the shape is stable and so the readiness endpoint can point at it as the remedy it already names.What to do instead, today. Credentials are operator-issued: email developers@phosra.com with your DID (or the display name you want derived), your canonical application origin, and whether you need sandbox or production. The operator lane is POST /api/v1/admin-ops/link/credentials — super-admin gated on the production census, and compiled out entirely on the sandbox censuses (404 under SANDBOX_MODE), which is why no sandbox host can mint one for you either. Platform onboarding also seeds the census directory row that your credential must match — see the directory row.

The request

uuid
required
Your developer organization. You must be an admin of it. Create one with POST /api/v1/developers/orgs.
string
required
8–128 characters, starting alphanumeric, drawn from [A-Za-z0-9._:-]. Required — omitting it is a 400. See what it does and does not do below; it is a front-door contract, not the replay key.
string
required
Display name, at most 96 bytes. Your DID is derived from it — you cannot choose a DID, which is what makes name-squatting structurally impossible rather than a validation rule someone can forget. Control, bidirectional-override, zero-width and other invisible code points are refused before anything is created.
string
required
Exactly "platform" or "provider". A provider publishes a Link client registration and gets entry_role=enforcement-agent; a platform publishes neither and skips stage 4.
string[]
required
1–16 URLs, no duplicates, no query, no fragment, no userinfo. The origin of the alphabetically first entry becomes your application origin, and it must be canonical: lowercase host, and port 443 must not be spelled out.HTTPS always — plus, on a sandbox census only, http://localhost[:port] and http://127.0.0.1[:port]. That loopback allowance is the local-dev lane, and it is real: a sandbox census publishes redirect_policy.allow_http_loopback: true with http_loopback_hosts: ["127.0.0.1","localhost"] in its signed environment manifest, and the production manifest publishes false with an empty host list. A production credential is canonical-HTTPS-origin only.
Check the lane before you rely on it, rather than trusting this page:
The manifest is a compact JWS behind a phosra_env_v1. prefix, so it has four dot-separated parts and the payload is part 3 — not the 3-part layout a standard JWS decoder expects.Ports are part of the origin, and an issued credential cannot be edited: changing your dev port means a new credential.
The single most common failure, and it is worth reading twice. Every redirect_uris entry must have the exact path /api/phosra/link/callback. Nothing else validates.This is not checked by the pre-flight. A wrong path passes validation, an identity and an org↔DID binding are durably created, and the request then fails at stage 4 — consuming your organization’s one app slot. See Recovering from a mid-ceremony failure.
Unknown body members are ignored by design. Sending did, slug, or provider_did does nothing — identity is always derived server-side.

Create your org first

X-Sandbox-Session is a sandbox-census-only convenience: that header auto-provisions a throwaway user, which is why these examples need no bearer token. On the production census the same routes take your WorkOS session bearer instead. The header is refused at startup on any host without SANDBOX_MODE=true or an explicit non-production PHOSRA_ENV.

Creating a platform

201 Created — real response, cache-control: no-store:
writer_seed is shown exactly once and Phosra never stores it. It is not logged, not persisted, and cannot be re-read — a replay of this request returns no seed at all. Capture it in the same step that runs the call. Losing it means creating a new app in a new organization.That is deliberate: a census that could re-show your seed would be a census that stores every developer’s signing key.

Creating a provider

Identical, with "kind": "provider". Stage 4 publishes your Link client registration instead of skipping, so redirect_uris is load-bearing:

The status field, honestly

status is the field to branch on. It reports what actually happened, and pending_accreditation is a normal, expected outcome on a healthy census — not an error.
Why pending_accreditation is the honest default. Self-registration hardcodes tier provisional and is never elevated by self-service. Credential issuance requires an accredited Trust List entry. So a brand-new app legitimately stops one step short of a credential, and the endpoint says so in plain words rather than pretending it succeeded or failing outright. The identity, the key, and the client registration are all real and all keep working.

Idempotency

Two mechanisms, and it matters which is which. The Idempotency-Key header is a front-door contract: it must be well-formed, but it is not the key that decides replay. A caller-chosen value is neither unique across tenants nor stable across the retries this endpoint has to survive. The durable key is your org’s binding. v1 is one app per organization — your org’s claimed DID is the app identity. So re-POSTing replays even under a different Idempotency-Key:
200 OK — real response:
Note created: false, the absent writer_seed, and — importantly — that a replay is a resume, not a receipt. It re-drives every stage that is not already complete. In the run above, stage 4 had failed at creation and the replay published it.

One app per organization

Re-POSTing with a different name on an already-bound org is a 409, not a second identity:

Recovering from a mid-ceremony failure

Stages 1 and 2 create durable state. If a later stage fails, the endpoint never discards what it made and never drops the key — dropping it would brick the identity permanently: orphaned on the census, unclaimable (the collision guard refuses to re-register the slug), and unsignable. This is the real 400 from a provider create whose redirect_uris used /phosra/callback instead of /api/phosra/link/callback:
Three fields make this recoverable, and all three are only present because an identity exists:
  • state_created — one line naming exactly what survives.
  • failed_stage + stages — where it stopped.
  • writer_seed — your only copy of the key, handed over on the way out.
To recover: fix the input and re-POST with the same name. That replays, resumes at the failed stage, and completes it — exactly the 200 shown above. Do not change the name: a different name on a bound org is the 409.

Refusal codes by stage

Two front-door refusals carry no stage:
  • 503 app_factory_unavailable — this census does not host the funnel (it needs the self-register lane, the Link authority, and a Trust List). Nothing was created.
  • 503 app_factory_busy — the one-app-per-organization lock could not be taken. Nothing was created. Retry.

Then: two environment variables

Everything a running integration needs is in the response you just received.
A platform needs more than the credential, and two of the extras are not in this response.
  • authorizedProviders is a required, non-empty allowlist, and every DID on it must be active + accredited + role: enforcement-agent on the census, or the platform will not boot. It is authorization, not discovery: putting a DID on the list says “this company may write enforcement rules into my product,” and follows a commercial agreement. role: enforcement-agent on the trust list does not distinguish a rule-writing provider from another platform — most entries carrying it today are platforms. Do not copy an example DID. See Who goes in authorizedProviders.
  • You must publish a directory row and serve a profiles_url route yourself. The credential alone does not make a platform bootable or connectable. See the four preconditions.
createLink requires writerKeyId whenever writerSeed is a bare seed — a seed alone can never name a DID. The funnel returns it as key_id, already in the did:ocss:<slug>#<kid> form the SDK expects, so you never assemble it yourself.For a census host outside the SDK’s bundled pin set you must also pass trustRoot (the base64url-raw X of that census’s OCSS root). There is no trust-on-first-use. https://prodapi.phosra.com entered the bundled pin table in @phosra/link 0.7.57; on 0.7.56 and earlier that host is unpinned and createLink will refuse it without an explicit trustRoot.
No kid to invent. No key handling. No operator ticket.

Verify it worked

Do not guess. @phosra/gatekeeper ≥ 0.8.68 serves a readiness endpoint that names the exact outstanding precondition:
PLATFORM_READY is not “the integration works.” Readiness proves boot preconditions only. It cannot see that your published profiles_url 404s (the catch-all does not serve that route — you do), that your worker is not running (nothing is ever delivered without it), or that your DID is missing from the census’s consent-attestation roster. Check those three by hand — see after PLATFORM_READY.

The readiness contract — every precondition and its fix

What PLATFORM_SIGNING_KEY_MISMATCH, PLATFORM_STANDING_NOT_ACCREDITED and the other eleven codes mean, and what you do about each.

Already provisioned the old way? Read the divergence note

Why a hand-minted credential can drift from the census, how to detect it, and which existing platforms are exposed.

@phosra/gatekeeper — createPlatform

What a platform must publish and serve beyond the credential: the directory row, the profiles endpoint, authorizedProviders, the worker, and mounting outside Next.js.