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

# Platform Registration

> Mint your platform endpoint, receive the connect-secret once, declare your webhook URL, and wire the six PHOSRA_* env vars into @phosra/gatekeeper. This is the one-time step that opens the connect channel a provider uses to reach you.

Before a provider can complete the connect ceremony and start delivering `endpoint_id_label`s
to your app, your platform registers **one endpoint** with the census. Registration:

1. **declares your webhook URL** — the base URL your `POST /api/ocss/connect` receiver lives under, and
2. **returns two secrets exactly once** — the `endpoint_id_label` (your §9.3(a) registration name) and the `connect_secret` (the HMAC key that authenticates inbound connect-leg deliveries).

This is the EXT-04 §3.5 out-of-band establishment step made a real operation. Reference
implementation: `internal/ocsshttp/handler_platform_endpoints.go` (`Mint`) over
`internal/ocss/profile/platform.go` (`MintPlatformEndpoint`).

<Note>
  **One endpoint, self-scoped.** You register **your own** platform DID's endpoint — the
  signed request's caller DID must equal the `{did}` in the path. A mismatch answers the
  same `404` an unknown platform answers (no existence leak). Re-minting the same
  `(did, connect_url)` pair **rotates both credentials** — that is the EXT-04 §3.5 re-issue path.
</Note>

***

## Prerequisites

| Item                                     | Source                                                                                                                                                                                                                                     |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `did:ocss:<your-slug>` on the Trust List | **Sandbox:** self-serve — [`POST /api/v1/advisors/self-register`](/ocss/onboarding#get-a-sandbox-did-self-serve-no-email-required) or `phosra register`. **Production:** [apply for accreditation](/integration/production-accreditation). |
| Ed25519 signing key pair                 | `@openchildsafety/ocss` — a `SenderKey` is `{ seed, keyID }`; the same key your DID publishes on its Trust-List entry.                                                                                                                     |
| Canonical sandbox census URL             | `https://phosra-api-sandbox-production.up.railway.app` — the one canonical partner sandbox host (see [Conformance status](/ocss/status)).                                                                                                  |

***

## Step 1 — Mint your endpoint — `POST /api/v1/platforms/{did}/endpoints`

The request is **RFC-9421 signed** with your DID's Ed25519 key (the census verifies the
signature against your live Trust-List entry — provisional-tier self-registrations included).
There is no `phosra_` API key on this path; the census identifies you by signature, not by a
bearer token.

**Request body:**

| Field          | Type      | Required | Meaning                                                                                                                                                                                                                                           |
| -------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `connect_url`  | string    | yes      | Absolute base URL your `POST /api/ocss/connect` receiver lives under. **HTTPS required outside sandbox** (sandbox admits plain `http` for private-network sim receivers). No query, fragment, or embedded credentials; a trailing `/` is trimmed. |
| `capabilities` | string\[] | no       | Declared capability slugs (informative; the signed gatekeeper capabilities document remains the enforced artifact). Lowercase slugs `^[a-z][a-z0-9_.:-]{0,63}$`, max 128, de-duplicated.                                                          |

Because the request must be signed, mint through the SDK rather than raw `curl`. Using
`signRequest` from `@openchildsafety/ocss`:

```ts theme={null}
import { signRequest } from "@openchildsafety/ocss"

const CENSUS = "https://phosra-api-sandbox-production.up.railway.app"
const did    = "did:ocss:snaptr"
const key    = mySenderKey                       // { seed: Uint8Array(32), keyID: "did:ocss:snaptr#snaptr-2026-06" }

const bodyText  = JSON.stringify({
  connect_url:  "https://snaptr.example.com/webhooks/ocss-connect",  // your receiver base
  capabilities: ["content_rating", "addictive_pattern_block"],
})
const targetURI = `${CENSUS}/api/v1/platforms/${encodeURIComponent(did)}/endpoints`

// RFC-9421: signRequest signs the request line + Content-Digest with STANDARD base64 (padded, +/).
const headers = signRequest({
  method:  "POST",
  targetURI,
  body:    new TextEncoder().encode(bodyText),
  keyID:   key.keyID,
  seed:    key.seed,
  created: Math.floor(Date.now() / 1000),
})
headers["Content-Type"] = "application/json"

const res  = await fetch(targetURI, { method: "POST", headers, body: bodyText })
const mint = await res.json()
```

**`201` response — the two secrets are here and nowhere else:**

```json theme={null}
{
  "endpoint_id":       "3f0e…-uuid",
  "endpoint_id_label": "eplbl_9c2f…",
  "connect_secret":    "cs_7b41…",
  "connect_url":       "https://snaptr.example.com/webhooks/ocss-connect",
  "capabilities":      ["content_rating", "addictive_pattern_block"],
  "rotated_at":        "2026-07-03T18:22:04Z"
}
```

<Warning>
  **`endpoint_id_label` and `connect_secret` are returned only once.** Per §9.3, the census
  stores only their SHA-256 digests (migration `191_ocss_platform_endpoints`) — the cleartexts
  live in this `201` body and nowhere else. They are **never** logged, receipted, or exported.
  Persist both immediately (a secret manager, not source). Lose them and you re-mint, which
  **rotates both** and invalidates the old pair.
</Warning>

### What each returned value is for

| Value               | Used for                                                                                                                                                                                                                                       |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `endpoint_id`       | The stable UUID of your registration (safe to log).                                                                                                                                                                                            |
| `endpoint_id_label` | Your §9.3(a) registration **name** — an opaque credential a provider references when it completes the connect ceremony to you. Treat like a session credential; never log it. Feeds `PHOSRA_ENDPOINT_ID` / `createGatekeeper({ endpointId })`. |
| `connect_secret`    | The HMAC key that authenticates inbound connect-leg deliveries (Step 3). Feeds `PHOSRA_CONNECT_SECRET` / `createGatekeeper({ connectSecret })`.                                                                                                |
| `connect_url`       | Echo of your declared webhook base (normalized).                                                                                                                                                                                               |

***

## Step 2 — Declare your webhook receiver

The `connect_url` you registered is the base your `POST /api/ocss/connect` receiver lives
under. When a provider completes the connect ceremony, Phosra delivers the
`endpoint_id_label` **server-to-server** to that URL. Stand up the receiver and hand it to
`@phosra/gatekeeper`'s `handleConnect` — see [Platform Quickstart → Step 1](/integration/platform#step-1-receive-the-connection-handleconnect):

```ts theme={null}
// POST /webhooks/ocss-connect  (must match the connect_url you minted, sans the appended path)
app.post("/webhooks/ocss-connect", express.raw({ type: "application/json" }), async (req, res) => {
  const webReq = new Request("https://snaptr.example.com/webhooks/ocss-connect", {
    method:  "POST",
    headers: { ...(req.headers as Record<string, string>) },
    body:    req.body,   // raw Buffer — do NOT pre-parse; the HMAC is over exact bytes
  })
  const response = await gk.handleConnect(webReq)
  res.status(response.status).json(await response.json())
})
```

The sender appends `/api/ocss/connect` to your registered base, so register the **base**
(e.g. `https://snaptr.example.com/webhooks/ocss-connect`) — the SDK path convention and the
Go sender (`internal/ocss/link/sender.go`) agree on this.

***

## Step 3 — Verify inbound deliveries with the connect-secret

Phosra signs every connect-leg delivery with an `X-Phosra-Signature` header so your receiver
can prove the request came from Phosra and was not tampered with.

**Algorithm:** `X-Phosra-Signature = lowercase_hex( HMAC-SHA256( connect_secret, rawRequestBody ) )`
(reference: `internal/ocss/link/sender.go` `Signature` / `VerifySignature`).

Pass the `connect_secret` from Step 1 into `createGatekeeper` and the SDK verifies it for you,
**fail-closed**:

```ts theme={null}
const gk = createGatekeeper({
  // ...bases + signing key...
  connectSecret: process.env.PHOSRA_CONNECT_SECRET,  // from the endpoint-mint 201
})
```

| Condition                                      | `handleConnect` result                                            |
| ---------------------------------------------- | ----------------------------------------------------------------- |
| `connectSecret` set, signature matches         | Accept — persist label, attempt `refreshProfile`                  |
| `connectSecret` set, signature absent or wrong | **HTTP 401** (fail-closed)                                        |
| Same `endpoint_id_label` seen again (replay)   | **HTTP 200** immediately, no second `refreshProfile` (idempotent) |
| No `connectSecret` configured                  | No signature check — **trusted-network only**                     |

<Warning>
  **Always configure `connectSecret` in production.** Without it, `handleConnect` accepts any
  POST to your webhook. The `connect_secret` is the only thing that binds an inbound delivery to
  Phosra.
</Warning>

***

## The `@phosra/gatekeeper` env contract — the six `PHOSRA_*` vars

A gatekeeper deployment is fully parametrized by **six** `PHOSRA_*` environment variables plus
one secret signing seed. Everything `createGatekeeper` needs maps 1:1 onto them:

| Env var                  | `createGatekeeper` field | Value / source                                                                                            |
| ------------------------ | ------------------------ | --------------------------------------------------------------------------------------------------------- |
| `PHOSRA_PLATFORM_DID`    | `platformDid`            | `did:ocss:<your-slug>` — your Trust-List DID                                                              |
| `PHOSRA_PLATFORM_KEY_ID` | `platformKeyId`          | Your signing key id, e.g. `did:ocss:snaptr#snaptr-2026-06` (the `keyID` your DID publishes)               |
| `PHOSRA_CENSUS_URL`      | `censusBaseUrl`          | `https://phosra-api-sandbox-production.up.railway.app` (canonical sandbox host)                           |
| `PHOSRA_TRUST_ROOT_X`    | `trustRootXB64Url`       | `CMHWy3vUAiEcYDdE_bDvkRuEqwxkklS0tV-TYHJTlWU` — root pubkey X (base64url, **public**, pinned out-of-band) |
| `PHOSRA_ENDPOINT_ID`     | `endpointId`             | The `endpoint_id_label` from your endpoint-mint `201` (populated after the connect ceremony binds you)    |
| `PHOSRA_CONNECT_SECRET`  | `connectSecret`          | The `connect_secret` from your endpoint-mint `201`                                                        |

Plus the **signing seed** — the one genuinely sensitive value, kept out of the six because it
is a private key, not configuration:

| Secret                | Builds                      | Value / source                                                                           |
| --------------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
| `PHOSRA_SIGNING_SEED` | `gatekeeperSigningKey.seed` | base64url of your Ed25519 **32-byte** seed. Load from a secret manager; never commit it. |

```bash theme={null}
# .env — the six PHOSRA_* config vars (safe to template; the SEED is a secret)
PHOSRA_PLATFORM_DID=did:ocss:snaptr
PHOSRA_PLATFORM_KEY_ID=did:ocss:snaptr#snaptr-2026-06
PHOSRA_CENSUS_URL=https://phosra-api-sandbox-production.up.railway.app
PHOSRA_TRUST_ROOT_X=CMHWy3vUAiEcYDdE_bDvkRuEqwxkklS0tV-TYHJTlWU
PHOSRA_ENDPOINT_ID=eplbl_9c2f…            # endpoint_id_label from the mint 201
PHOSRA_CONNECT_SECRET=cs_7b41…            # connect_secret from the mint 201
# secret — from a vault, not this file:
# PHOSRA_SIGNING_SEED=<base64url 32-byte Ed25519 seed>
```

```ts theme={null}
import { createGatekeeper } from "@phosra/gatekeeper"
import { b64urlRawDecode } from "@openchildsafety/ocss"

const gk = createGatekeeper({
  platformDid:          process.env.PHOSRA_PLATFORM_DID!,
  platformKeyId:        process.env.PHOSRA_PLATFORM_KEY_ID!,
  censusBaseUrl:        process.env.PHOSRA_CENSUS_URL!,
  trustRootXB64Url:     process.env.PHOSRA_TRUST_ROOT_X!,
  endpointId:           process.env.PHOSRA_ENDPOINT_ID!,
  connectSecret:        process.env.PHOSRA_CONNECT_SECRET!,
  gatekeeperSigningKey: {
    seed:  b64urlRawDecode(process.env.PHOSRA_SIGNING_SEED!),   // Uint8Array(32)
    keyID: process.env.PHOSRA_PLATFORM_KEY_ID!,
  },
  ratingMappings: [{ ocssCategory: "content_rating", myField: "mpaaRating", vocabulary: "mpaa" }],
})
```

<Note>
  `PHOSRA_TRUST_ROOT_X` is a **public** key pinned out-of-band on purpose — that is what root
  verification means: you do not fetch the root from the census you are about to verify. The
  staging census is a Phosra-internal pre-release instance with a different root and is **not** a
  partner surface — always use the canonical production sandbox host above.
</Note>

***

## Conformance

The signed endpoint-mint (Step 1) needs your DID key, so it is exercised by the SDK, not raw
`curl`. What a docs-only stranger — or the nightly docs-conformance CI — **can** run with no
credentials is the reachability + trust-anchor check the whole registration depends on: the
canonical census serves a root-signed Trust List whose `root` matches the `PHOSRA_TRUST_ROOT_X`
pinned above.

```bash theme={null}
# 1. The canonical sandbox census is reachable and healthy.
curl -fsS https://phosra-api-sandbox-production.up.railway.app/health
# → 200 {"status":"ok"}

# 2. It serves a root-signed Trust List whose root key_id is the sandbox root.
curl -fsS https://phosra-api-sandbox-production.up.railway.app/.well-known/ocss/trust-list \
  | grep -o '"key_id":"root-sandbox-2026-06"'
# → "key_id":"root-sandbox-2026-06"   (non-empty ⇒ PASS)
```

A signed endpoint-mint round-trip (mint → receive `endpoint_id_label` at your webhook →
`handleConnect` 200 → `refreshProfile` → `isAllowed`) is exercised end-to-end by
`scripts/sandbox/verify-web-engagement-rule.mjs`, which exits `0` only if the full chain
survives against the live sandbox census.

***

## Next

* [Platform Quickstart](/integration/platform) — receive the connection, fetch/verify the profile, enforce locally, confirm
* [Onboarding](/ocss/onboarding) — get your sandbox `did:ocss:<slug>` (self-serve, no email)
* [Conformance status](/ocss/status) — the one canonical sandbox host and every live/preview surface
