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.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
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.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:
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. TheIdempotency-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:
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 a409, 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 real400 from a provider create whose redirect_uris used /phosra/callback
instead of /api/phosra/link/callback:
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.
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.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.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:
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.