Platform Registration

Before a provider can complete the connect ceremony and start delivering endpoint_id_labels 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 OCSS Trust Framework §3.5 out-of-band establishment step made a real operation — the public endpoint below is its normative surface; the @phosra/gatekeeper SDK wraps it end to end.

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.


Prerequisites

ItemSource
did:ocss:<your-slug> on the Trust ListSandbox: self-serve — POST /api/v1/advisors/self-register or phosra register. The result is a synthetic test identity. Production hosted routing: roadmap; see the design-partner beta.
Ed25519 signing key pairA SenderKey is { seed, keyID }; the same key your DID publishes on its Trust-List entry. Your keyID is did:ocss:<slug>#<kid> — see the key-id note below.
Canonical sandbox census URLhttps://phosra-api-sandbox-production.up.railway.app — the one canonical partner sandbox host (see Conformance status).

Read your key_id back — never construct it. Every DID on the Trust List — seeded roster or self-registered — publishes a plain did:ocss:<slug>#<YYYY-MM> kid, and the month is not guessable: did:ocss:snaptr publishes #2026-07, did:ocss:loopline publishes #2026-06. There is no slug-YYYY-MM "bespoke" form. For a self-registered sandbox DID the census binds your key under the UTC month at registration and returns your key_id directly in the self-register 200 body — use it verbatim. Signing with any kid that is not the one your entry publishes fails 401 (kid-not-in-entry). Read it back from your entry — see Test in the sandbox → Self-register a provisional DID — and use it verbatim everywhere PHOSRA_PLATFORM_KEY_ID / keyID appears.


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:

FieldTypeRequiredMeaning
connect_urlstringyesAbsolute 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.
capabilitiesstring[]noDeclared 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
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#2026-07" }
 
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 (secret-bearing values redacted):

json
{
  "capabilities":      ["content_rating", "addictive_pattern_block"],
  "connect_secret":    "REDACTED_SANDBOX_CONNECT_SECRET",
  "connect_url":       "https://snaptr.example.com/webhooks/ocss-connect",
  "endpoint_id":       "3f0e2c54-1222-497a-bc20-f25403c45a2b",
  "endpoint_id_label": "REDACTED_SANDBOX_ENDPOINT_ID_LABEL",
  "rotated_at":        "2026-07-03T18:22:04Z"
}

endpoint_id_label and connect_secret are each an unprefixed 43-character base64url string (a raw 32-byte secret, -_ alphabet, no eplbl_/cs_ prefix). The placeholders above are intentionally invalid and cannot be used as credentials.

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.

What each returned value is for

ValueUsed for
endpoint_idThe stable UUID of your registration (safe to log).
endpoint_id_labelYour §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_secretThe HMAC key that authenticates inbound connect-leg deliveries (Step 3). Feeds PHOSRA_CONNECT_SECRET / createGatekeeper({ connectSecret }).
connect_urlEcho 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:

ts
// 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 and the census sender agree on this path convention.


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 ) ). @phosra/gatekeeper's handleConnect implements the verify side for you.

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

ts
const gk = createGatekeeper({
  // ...bases + signing key...
  connectSecret: process.env.PHOSRA_CONNECT_SECRET,  // from the endpoint-mint 201
})
ConditionhandleConnect result
connectSecret set, signature matchesAccept — persist label, attempt refreshProfile
connectSecret set, signature absent or wrongHTTP 401 (fail-closed)
Same endpoint_id_label seen again (replay)HTTP 200 immediately, no second refreshProfile (idempotent)
No connectSecret configuredHTTP 503 — fail-closed (§3.6 C3; since @phosra/[email protected]). Set allowUnauthenticatedConnect: true for a trusted-network passthrough. Always set connectSecret.

Always configure connectSecret in production. As of @phosra/[email protected], handleConnect fails closed (503) when connectSecret is unset — unless you set allowUnauthenticatedConnect: true for a trusted-network deployment. The connect_secret is what binds an inbound delivery to Phosra.


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 varcreateGatekeeper fieldValue / source
PHOSRA_PLATFORM_DIDplatformDiddid:ocss:<your-slug> — your Trust-List DID
PHOSRA_PLATFORM_KEY_IDplatformKeyIdYour signing key id, e.g. did:ocss:snaptr#2026-07 (the keyID your DID publishes)
PHOSRA_CENSUS_URLcensusBaseUrlhttps://phosra-api-sandbox-production.up.railway.app (canonical sandbox host)
PHOSRA_TRUST_ROOT_XtrustRootXB64UrlCMHWy3vUAiEcYDdE_bDvkRuEqwxkklS0tV-TYHJTlWU — root pubkey X (base64url, public, pinned out-of-band)
PHOSRA_ENDPOINT_IDendpointIdThe endpoint_id_label from your endpoint-mint 201 (populated after the connect ceremony binds you)
PHOSRA_CONNECT_SECRETconnectSecretThe 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:

SecretBuildsValue / source
PHOSRA_SIGNING_SEEDgatekeeperSigningKey.seedbase64url of your Ed25519 32-byte seed. Load from a secret manager; never commit it.
bash
# .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#2026-07
PHOSRA_CENSUS_URL=https://phosra-api-sandbox-production.up.railway.app
PHOSRA_TRUST_ROOT_X=CMHWy3vUAiEcYDdE_bDvkRuEqwxkklS0tV-TYHJTlWU
PHOSRA_ENDPOINT_ID=REDACTED_SANDBOX_ENDPOINT_ID_LABEL
PHOSRA_CONNECT_SECRET=REDACTED_SANDBOX_CONNECT_SECRET
# secret — from a vault, not this file:
# PHOSRA_SIGNING_SEED=<base64url 32-byte Ed25519 seed>
ts
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" }],
})

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.


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
# 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 → refreshProfileisAllowed) 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 — receive the connection, fetch/verify the profile, enforce locally, confirm
  • Onboarding — get your sandbox did:ocss:<slug> (self-serve, no email)
  • Conformance status — the one canonical sandbox host and every live/preview surface