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:
- declares your webhook URL — the base URL your
POST /api/ocss/connect receiver lives under, and
- 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).
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
| Item | Source |
|---|
did:ocss:<your-slug> on the Trust List | Sandbox: self-serve — POST /api/v1/advisors/self-register or phosra register. Production: apply for 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). |
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:
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:
{
"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"
}
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
| 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:
// 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:
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 |
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.
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. |
# .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>
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.
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.
# 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 — 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