> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phosra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Create an app — the one call

> POST /developers/orgs/{orgId}/apps runs the whole onboarding ceremony server-side: keypair, DID, claim, Trust List entry, Link client, credential — minted from one key, so the credential and the census can never disagree.

# 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:

<Steps>
  <Step title="identity">Generates an Ed25519 keypair and self-registers a DID derived from your app name.</Step>
  <Step title="binding">Runs the real claim ceremony with that key, binding the DID to your org.</Step>
  <Step title="trust_list">Recompiles the census Trust List and requires the entry to actually resolve before anything is minted.</Step>
  <Step title="client_registration">Publishes your Link client registration (providers only — platforms skip this).</Step>
  <Step title="credential">Mints the `PHOSRA_CREDENTIAL` <strong>from the same key generated in step 1</strong>.</Step>
</Steps>

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.

<Info>
  **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](#the-status-field-honestly).
</Info>

## 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.

<Note>
  **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.
</Note>

***

## Where this runs today

<Warning>
  **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:

  | Host                                                             | `POST …/orgs/{id}/apps`                 | A deliberately bogus sibling path  | Reading                          |
  | ---------------------------------------------------------------- | --------------------------------------- | ---------------------------------- | -------------------------------- |
  | `phosra-api-sandbox-staging.up.railway.app`                      | `403 not a member of this organization` | `404 page not found`               | **Route exists**                 |
  | `phosra-api-sandbox-production.up.railway.app` (partner sandbox) | `404 page not found`                    | `404 page not found`               | Not deployed                     |
  | `sandbox.phosra.com`                                             | `404 page not found`                    | `404 page not found`               | Not deployed                     |
  | `prodapi.phosra.com` (production)                                | `401 missing authorization header`      | `401 missing authorization header` | **Cannot be probed** — see below |

  **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](/integration/platform-readiness) can point at it
  as the remedy it already names.

  **What to do instead, today.** Credentials are **operator-issued**: email
  [developers@phosra.com](mailto: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](/sdks/gatekeeper#1-your-own-directory-row).
</Warning>

***

## The request

<ParamField path="orgId" type="uuid" required>
  Your developer organization. You must be an **admin** of it. Create one with
  `POST /api/v1/developers/orgs`.
</ParamField>

<ParamField header="Idempotency-Key" type="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](#idempotency) below; it is a front-door
  contract, not the replay key.
</ParamField>

<ParamField body="name" type="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.
</ParamField>

<ParamField body="kind" type="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.
</ParamField>

<ParamField body="redirect_uris" type="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.
</ParamField>

<Note>
  **Check the lane before you rely on it**, rather than trusting this page:

  ```bash theme={null}
  curl -s https://phosra-api-sandbox-staging.up.railway.app/.well-known/phosra/environment-manifest-v1 \
  | cut -d. -f3 | base64 -d 2>/dev/null | jq '.configuration.redirect_policy'
  # → { "allow_http_loopback": true, "http_loopback_hosts": ["127.0.0.1", "localhost"] }
  ```

  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.**
</Note>

<Warning>
  **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](#recovering-from-a-mid-ceremony-failure).
</Warning>

<Note>
  **Unknown body members are ignored by design.** Sending `did`, `slug`, or `provider_did` does
  nothing — identity is always derived server-side.
</Note>

### Create your org first

```bash theme={null}
curl -sS -X POST https://phosra-api-sandbox-staging.up.railway.app/api/v1/developers/orgs \
  -H 'content-type: application/json' \
  -H 'X-Sandbox-Session: my-session' \
  -d '{"name":"Docs Verify Platform Org"}'
```

```json theme={null}
{
  "id": "233e2daa-7dcf-4109-b82a-e722b6467948",
  "name": "Docs Verify Platform Org",
  "slug": "docs-verify-platform-org-…",
  "tier": "free",
  "rate_limit_rpm": 100
}
```

<Note>
  `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`.
</Note>

***

## Creating a platform

```bash theme={null}
curl -sS -X POST \
  "https://phosra-api-sandbox-staging.up.railway.app/api/v1/developers/orgs/233e2daa-7dcf-4109-b82a-e722b6467948/apps" \
  -H 'content-type: application/json' \
  -H 'X-Sandbox-Session: my-session' \
  -H 'Idempotency-Key: docs-verify-platform-0001' \
  -d '{
        "name": "Docs Verify Platform",
        "kind": "platform",
        "redirect_uris": ["https://docs-verify-platform.example.com/api/phosra/link/callback"]
      }'
```

**`201 Created`** — real response, `cache-control: no-store`:

```json theme={null}
{
  "app_id": "3306d1d6-a6d7-4200-9c77-a72dcfa6adb5",
  "did": "did:ocss:docs-verify-platform-222db0",
  "kind": "platform",
  "key_id": "did:ocss:docs-verify-platform-222db0#2026-07",
  "writer_seed": "b-OQcV_i5ZLAkPDaNp_sYqNFqCVWYuZP5xJlH82m4nU",
  "status": "pending_accreditation",
  "created": true,
  "stages": [
    { "stage": "identity",            "state": "done",    "detail": "did:ocss:docs-verify-platform-222db0 (provisional)" },
    { "stage": "binding",             "state": "done" },
    { "stage": "trust_list",          "state": "done",    "detail": "tier provisional" },
    { "stage": "client_registration", "state": "skipped", "detail": "platforms publish no Link client registration" },
    { "stage": "credential",          "state": "refused", "detail": "the Link authority will not issue for tier provisional: a credential requires an accredited Trust List entry. …" }
  ],
  "note": "writer_seed is shown once and is not stored by Phosra. Save it now."
}
```

<Warning>
  **`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.
</Warning>

***

## Creating a provider

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

```bash theme={null}
curl -sS -X POST \
  "https://phosra-api-sandbox-staging.up.railway.app/api/v1/developers/orgs/$ORG/apps" \
  -H 'content-type: application/json' \
  -H 'X-Sandbox-Session: my-session' \
  -H 'Idempotency-Key: docs-verify-provider-0002' \
  -d '{
        "name": "Docs Verify Provider",
        "kind": "provider",
        "redirect_uris": ["https://docs-verify.example.com/api/phosra/link/callback"]
      }'
```

***

## 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.

| `status`                | HTTP      | What it means                                                                                                                                                                                                        | What you do                                                                                                                  |
| ----------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `ready`                 | 201 / 200 | Every stage completed. The response carries `credential`.                                                                                                                                                            | Set it and deploy.                                                                                                           |
| `pending_accreditation` | 201 / 200 | Identity, binding, Trust List entry and (for providers) client registration all exist. The Link authority refused to mint because a self-registered DID is **`provisional`** and issuance requires **`accredited`**. | Pursue accreditation, then **re-POST the same request** — the credential is issued on the replay. Nothing needs re-creating. |
| `pending_credential`    | 201 / 200 | Everything exists, but issuance failed for a transport or authority reason rather than a policy one.                                                                                                                 | Retry with a fresh `Idempotency-Key`.                                                                                        |
| `already_exists`        | 200       | A credential was already issued for this app. Phosra does not retain issued credentials, so it cannot be shown again.                                                                                                | Issue a fresh one on the credential lane.                                                                                    |
| `incomplete`            | 200       | A replay found an earlier stage no longer satisfied, so nothing was minted. Read `stages` for which one.                                                                                                             | Fix the named stage; re-POST.                                                                                                |

<Info>
  **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.
</Info>

***

## 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`**:

```bash theme={null}
# Same app, DIFFERENT Idempotency-Key → replay, not a second identity.
curl -sS -X POST ".../orgs/$ORG/apps" \
  -H 'Idempotency-Key: docs-verify-provider-0002' \
  -d '{"name":"Docs Verify Provider","kind":"provider",
       "redirect_uris":["https://docs-verify.example.com/api/phosra/link/callback"]}'
```

**`200 OK`** — real response:

```json theme={null}
{
  "app_id": "dd7792f5-fb73-4312-a63d-bdeb036128cb",
  "did": "did:ocss:docs-verify-provider-077d56",
  "kind": "provider",
  "key_id": "did:ocss:docs-verify-provider-077d56#2026-07",
  "status": "pending_accreditation",
  "created": false,
  "stages": [
    { "stage": "identity",            "state": "done", "detail": "did:ocss:docs-verify-provider-077d56" },
    { "stage": "binding",             "state": "done" },
    { "stage": "trust_list",          "state": "done", "detail": "tier provisional" },
    { "stage": "client_registration", "state": "done", "detail": "published on this replay (it was missing)" },
    { "stage": "credential",          "state": "refused", "detail": "the Link authority will not issue for tier provisional: a credential requires an accredited Trust List entry. Re-POST this request once the entry is accredited and the credential will be issued." }
  ],
  "note": "this app already exists; its writer_seed was shown once at creation and is not stored by Phosra"
}
```

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:

```json theme={null}
{
  "error": "Conflict",
  "code": 409,
  "message": "app_already_exists: this organization is already bound to did:ocss:docs-verify-provider-077d56. One app per organization: use that app, or create another organization."
}
```

***

## 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`:

```json theme={null}
{
  "error": "Bad Request",
  "code": 400,
  "failed_stage": "client_registration",
  "message": "client_registration_invalid: the derived Link client registration is not valid (check the app name and redirect URIs)",
  "did": "did:ocss:docs-verify-provider-077d56",
  "key_id": "did:ocss:docs-verify-provider-077d56#2026-07",
  "writer_seed": "6Yb5v8NanEf7rl5ng7HjcpzyJpbLFal5cSYqdruKxn8",
  "state_created": "completed and still in place: identity, binding, trust_list",
  "stages": [
    { "stage": "identity",            "state": "done",   "detail": "did:ocss:docs-verify-provider-077d56 (provisional)" },
    { "stage": "binding",             "state": "done" },
    { "stage": "trust_list",          "state": "done",   "detail": "tier provisional" },
    { "stage": "client_registration", "state": "failed", "detail": "the derived Link client registration is not valid (check the app name and redirect URIs)" }
  ],
  "note": "an identity was created before this failed. writer_seed is its only key, is shown once, and is not stored by Phosra — save it, or the identity above becomes unusable."
}
```

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

| `failed_stage`        | `message` prefix                   | Meaning                                                                                                                                                                                |
| --------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `identity`            | `identity_name_unavailable`        | Every derived identifier for this name is taken. Choose a different name.                                                                                                              |
| `identity`            | `identity_registration_failed`     | The census refused registration; its own message is passed through unchanged.                                                                                                          |
| `identity`            | `identity_registration_unreadable` | The census response could not be read.                                                                                                                                                 |
| `binding`             | `binding_failed`                   | Registered, but the org↔DID claim did not complete. Retrying creates a **new** identity — the one named in the response stays registered and is reachable only with the returned seed. |
| `binding`             | `binding_not_resolvable`           | The binding did not resolve back to the registered identity.                                                                                                                           |
| `trust_list`          | `trust_list_unavailable`           | The Trust List could not be refreshed. Retry.                                                                                                                                          |
| `trust_list`          | `identity_not_resolvable`          | Registered but not active in the signed Trust List. No credential was issued.                                                                                                          |
| `client_registration` | `client_registration_invalid`      | **Almost always the callback path.** See the warning above.                                                                                                                            |
| `client_registration` | `client_registration_failed`       | The Link authority rejected or could not be reached.                                                                                                                                   |

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.

<CodeGroup>
  ```bash Platform theme={null}
  PHOSRA_CREDENTIAL=<the `credential` field from the response>
  DATABASE_URL=<your own Postgres>
  ```

  ```bash Provider theme={null}
  PHOSRA_WRITER_SEED=<the `writer_seed` field from the response>
  PHOSRA_WRITER_KEY_ID=<the `key_id` field from the response>
  DATABASE_URL=<your own Postgres>
  ```
</CodeGroup>

<CodeGroup>
  ```ts Platform theme={null}
  import { createPlatform } from "@phosra/gatekeeper"

  export const platform = createPlatform({
    credential: process.env.PHOSRA_CREDENTIAL!,
    onDiagnostic: (d) => console.error(JSON.stringify(d)),
    adapter: {
      database: pool,
      authorizedProviders: ["did:ocss:some-provider"], // NOT a default — see the note below
      // plus your own resolveAccount / apply / observe / release / observeRelease
    },
  })
  ```

  ```ts Provider theme={null}
  import { createLink } from "@phosra/link"

  export const link = createLink({
    census: "https://prodapi.phosra.com",
    writerSeed:  process.env.PHOSRA_WRITER_SEED!,    // the `writer_seed` field
    writerKeyId: process.env.PHOSRA_WRITER_KEY_ID!,  // the `key_id` field, e.g. did:ocss:acme#2026-07
    db: pool,
  })
  ```
</CodeGroup>

<Warning>
  **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`](/sdks/gatekeeper#3-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](/sdks/gatekeeper#the-four-preconditions-that-gate-boot).
</Warning>

<Note>
  `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`.
</Note>

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:

```bash theme={null}
curl -s https://your-app.example.com/api/phosra/status | jq .precondition
```

<Warning>
  **`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`](/integration/platform-readiness#what-readiness-cannot-check).
</Warning>

<Card title="The readiness contract — every precondition and its fix" icon="stethoscope" href="/integration/platform-readiness" horizontal>
  What `PLATFORM_SIGNING_KEY_MISMATCH`, `PLATFORM_STANDING_NOT_ACCREDITED` and the other eleven
  codes mean, and what you do about each.
</Card>

<Card title="Already provisioned the old way? Read the divergence note" icon="triangle-exclamation" href="/integration/credential-divergence" horizontal>
  Why a hand-minted credential can drift from the census, how to detect it, and which existing
  platforms are exposed.
</Card>

<Card title="@phosra/gatekeeper — createPlatform" icon="cube" href="/sdks/gatekeeper" horizontal>
  What a platform must publish and serve beyond the credential: the directory row, the profiles
  endpoint, `authorizedProviders`, the worker, and mounting outside Next.js.
</Card>
