# Signed headers are per-request — compute with the SDK (Node tab).
curl -X POST https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect \
-H "Content-Type: application/json" \
-H "OCSS-Spec-Version: OCSS-v1.0-pre" \
-H 'Signature-Input: ocss=("@method" "@target-uri" "ocss-spec-version" "content-digest");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"' \
-H 'Signature: ocss=:<base64-ed25519-sig>:' \
-H 'Content-Digest: sha-256=:<base64-sha256-of-body>:' \
-d '{"platform_did":"did:ocss:loopline","webhook_url":"https://your-gatekeeper.example.com","connect_secret":"cs_your_hmac_secret"}'import { signRequest } from "@openchildsafety/ocss"
const BASE = "https://phosra-api-sandbox-production.up.railway.app/api/v1"
const seed = new Uint8Array(Buffer.from("bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", "base64url"))
const keyID = "did:ocss:loopline#2026-06"
const body = { platform_did: "did:ocss:loopline", webhook_url: "https://your-gatekeeper.example.com", connect_secret: "cs_your_hmac_secret" }
const t = BASE + "/sandbox/test-connect", b = JSON.stringify(body)
const h = signRequest({ method: "POST", targetURI: t, body: new TextEncoder().encode(b), keyID, seed, created: Math.floor(Date.now() / 1000) })
h["Content-Type"] = "application/json"
const res = await fetch(t, { method: "POST", headers: h, body: b })
console.log(res.status, await res.json())
import requests
url = "https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect"
payload = {
"platform_did": "did:ocss:my-gatekeeper",
"webhook_url": "https://my-gatekeeper.example.com",
"connect_secret": "<string>",
"child_ref": "child:a11ce0fa-0000-4000-8000-0000000000a1",
"window_seconds": 123
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'platform_did' => 'did:ocss:my-gatekeeper',
'webhook_url' => 'https://my-gatekeeper.example.com',
'connect_secret' => '<string>',
'child_ref' => 'child:a11ce0fa-0000-4000-8000-0000000000a1',
'window_seconds' => 123
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect"
payload := strings.NewReader("{\n \"platform_did\": \"did:ocss:my-gatekeeper\",\n \"webhook_url\": \"https://my-gatekeeper.example.com\",\n \"connect_secret\": \"<string>\",\n \"child_ref\": \"child:a11ce0fa-0000-4000-8000-0000000000a1\",\n \"window_seconds\": 123\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"platform_did\": \"did:ocss:my-gatekeeper\",\n \"webhook_url\": \"https://my-gatekeeper.example.com\",\n \"connect_secret\": \"<string>\",\n \"child_ref\": \"child:a11ce0fa-0000-4000-8000-0000000000a1\",\n \"window_seconds\": 123\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"platform_did\": \"did:ocss:my-gatekeeper\",\n \"webhook_url\": \"https://my-gatekeeper.example.com\",\n \"connect_secret\": \"<string>\",\n \"child_ref\": \"child:a11ce0fa-0000-4000-8000-0000000000a1\",\n \"window_seconds\": 123\n}"
response = http.request(request)
puts response.read_body{
"binding_id": "22b577ea-4a53-4505-9eb5-541bf237cb42",
"resolver_did": "did:ocss:loopline",
"endpoint_id_label": "iWdlltz5F4sKbZKYgIvGkIJg7sylz2LN37cZUK8wiWY",
"profile_url": "/api/v1/enforcement-profiles/iWdlltz5F4sKbZKYgIvGkIJg7sylz2LN37cZUK8wiWY",
"connect_receiver": "https://loopline.example.com/api/ocss/connect",
"delivered": false,
"http_status": 0,
"state": "sandbox-test-connect:22b577ea-4a53-4505-9eb5-541bf237cb42",
"note": "callback delivery did not receive a 2xx — check webhook_url reachability and that connect_secret matches your gk.config; the binding is minted and profile_url is observable regardless"
}{
"error": "Bad Request",
"message": "platform_did is required",
"code": 400,
"class": "malformed"
}{
"error": "Unauthorized",
"message": "exactly one Signature-Input and one Signature header are required",
"code": 401,
"class": "signature_invalid"
}404 page not found
{
"error": "Too Many Requests",
"message": "rate limit exceeded",
"code": 429
}{
"error": "Internal Server Error",
"message": "internal error",
"code": 500
}{
"error": "Service Unavailable",
"message": "census operation not yet available",
"code": 503
}Run the connect ceremony to your own DID (sandbox)
SANDBOX-ONLY reference provider. Mints a §9.3 binding for a seeded test child scoped to your platform_did and delivers the signed §3.6 callback to your webhook, so a cold self-registered platform dev can watch a real signed enforcement profile land with no live counterparty and no OCSS_CONSENT_ATTESTATION_APPS roster edit (it skips §6.2 standing for the seeded fixture child). RFC 9421 signed as the caller. Gated on the Restricted band (PHOSRA_ENV==sandbox) — returns 404 on dev/staging/production.
# Signed headers are per-request — compute with the SDK (Node tab).
curl -X POST https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect \
-H "Content-Type: application/json" \
-H "OCSS-Spec-Version: OCSS-v1.0-pre" \
-H 'Signature-Input: ocss=("@method" "@target-uri" "ocss-spec-version" "content-digest");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"' \
-H 'Signature: ocss=:<base64-ed25519-sig>:' \
-H 'Content-Digest: sha-256=:<base64-sha256-of-body>:' \
-d '{"platform_did":"did:ocss:loopline","webhook_url":"https://your-gatekeeper.example.com","connect_secret":"cs_your_hmac_secret"}'import { signRequest } from "@openchildsafety/ocss"
const BASE = "https://phosra-api-sandbox-production.up.railway.app/api/v1"
const seed = new Uint8Array(Buffer.from("bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", "base64url"))
const keyID = "did:ocss:loopline#2026-06"
const body = { platform_did: "did:ocss:loopline", webhook_url: "https://your-gatekeeper.example.com", connect_secret: "cs_your_hmac_secret" }
const t = BASE + "/sandbox/test-connect", b = JSON.stringify(body)
const h = signRequest({ method: "POST", targetURI: t, body: new TextEncoder().encode(b), keyID, seed, created: Math.floor(Date.now() / 1000) })
h["Content-Type"] = "application/json"
const res = await fetch(t, { method: "POST", headers: h, body: b })
console.log(res.status, await res.json())
import requests
url = "https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect"
payload = {
"platform_did": "did:ocss:my-gatekeeper",
"webhook_url": "https://my-gatekeeper.example.com",
"connect_secret": "<string>",
"child_ref": "child:a11ce0fa-0000-4000-8000-0000000000a1",
"window_seconds": 123
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'platform_did' => 'did:ocss:my-gatekeeper',
'webhook_url' => 'https://my-gatekeeper.example.com',
'connect_secret' => '<string>',
'child_ref' => 'child:a11ce0fa-0000-4000-8000-0000000000a1',
'window_seconds' => 123
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect"
payload := strings.NewReader("{\n \"platform_did\": \"did:ocss:my-gatekeeper\",\n \"webhook_url\": \"https://my-gatekeeper.example.com\",\n \"connect_secret\": \"<string>\",\n \"child_ref\": \"child:a11ce0fa-0000-4000-8000-0000000000a1\",\n \"window_seconds\": 123\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"platform_did\": \"did:ocss:my-gatekeeper\",\n \"webhook_url\": \"https://my-gatekeeper.example.com\",\n \"connect_secret\": \"<string>\",\n \"child_ref\": \"child:a11ce0fa-0000-4000-8000-0000000000a1\",\n \"window_seconds\": 123\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"platform_did\": \"did:ocss:my-gatekeeper\",\n \"webhook_url\": \"https://my-gatekeeper.example.com\",\n \"connect_secret\": \"<string>\",\n \"child_ref\": \"child:a11ce0fa-0000-4000-8000-0000000000a1\",\n \"window_seconds\": 123\n}"
response = http.request(request)
puts response.read_body{
"binding_id": "22b577ea-4a53-4505-9eb5-541bf237cb42",
"resolver_did": "did:ocss:loopline",
"endpoint_id_label": "iWdlltz5F4sKbZKYgIvGkIJg7sylz2LN37cZUK8wiWY",
"profile_url": "/api/v1/enforcement-profiles/iWdlltz5F4sKbZKYgIvGkIJg7sylz2LN37cZUK8wiWY",
"connect_receiver": "https://loopline.example.com/api/ocss/connect",
"delivered": false,
"http_status": 0,
"state": "sandbox-test-connect:22b577ea-4a53-4505-9eb5-541bf237cb42",
"note": "callback delivery did not receive a 2xx — check webhook_url reachability and that connect_secret matches your gk.config; the binding is minted and profile_url is observable regardless"
}{
"error": "Bad Request",
"message": "platform_did is required",
"code": 400,
"class": "malformed"
}{
"error": "Unauthorized",
"message": "exactly one Signature-Input and one Signature header are required",
"code": 401,
"class": "signature_invalid"
}404 page not found
{
"error": "Too Many Requests",
"message": "rate limit exceeded",
"code": 429
}{
"error": "Internal Server Error",
"message": "internal error",
"code": 500
}{
"error": "Service Unavailable",
"message": "census operation not yet available",
"code": 503
}PHOSRA_ENV==sandbox — returns 404 on dev, staging, and
production.platform_did and delivers the signed §3.6 callback to your webhook_url. The call is
RFC 9421-signed as you.
Pick which seeded child with child_ref (optional). Omit it and the binding is minted for
Mia; pass a seeded child ref to bind a different one. This is how you exercise the
multi-profile model — one call per child yields a distinct endpoint_id_label per child,
which is exactly what a service with several kids’ profiles must map and enforce separately (see
Bind a connection to the right child):
| Seeded child | child_ref |
|---|---|
| Mia (default) | child:a11ce0fa-0000-4000-8000-0000000000a1 |
| Leo | child:a11ce0fa-0000-4000-8000-0000000000a2 |
| Ava | child:a11ce0fa-0000-4000-8000-0000000000a3 |
profile_url is observable even if the webhook delivery leg
fails — delivered=false with a note explaining why, but you can still poll the profile.
Worked example
import { signRequest } from "@openchildsafety/ocss"
const BASE = "https://phosra-api-sandbox-production.up.railway.app/api/v1"
const seed = new Uint8Array(Buffer.from("bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", "base64url"))
const keyID = "did:ocss:loopline#2026-06"
const targetURI = BASE + "/sandbox/test-connect"
const body = {
platform_did: "did:ocss:loopline",
webhook_url: "https://your-gatekeeper.example.com", // POST /api/ocss/connect receiver base
connect_secret: "your_connect_secret", // the unprefixed 43-char base64url secret you feed gk.config({ connectSecret })
child_ref: "child:a11ce0fa-0000-4000-8000-0000000000a2", // OPTIONAL — bind Leo; omit → Mia (see table above)
}
const bodyText = JSON.stringify(body)
const headers = signRequest({ method: "POST", targetURI, body: new TextEncoder().encode(bodyText), keyID, seed, created: Math.floor(Date.now() / 1000) })
headers["Content-Type"] = "application/json"
const res = await fetch(targetURI, { method: "POST", headers, body: bodyText })
console.log(res.status, await res.json())
curl -X POST https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect \
-H "Content-Type: application/json" \
-H "OCSS-Spec-Version: OCSS-v1.0-pre" \
-H 'Signature-Input: ocss=("@method" "@target-uri" "ocss-spec-version" "content-digest");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"' \
-H 'Signature: ocss=:<base64-ed25519-sig>:' \
-H 'Content-Digest: sha-256=:<base64-sha256-of-body>:' \
-d '{"platform_did":"did:ocss:loopline","webhook_url":"https://your-gatekeeper.example.com","connect_secret":"your_connect_secret","child_ref":"child:a11ce0fa-0000-4000-8000-0000000000a2"}'
200 response (captured — example.com returns 405 to the callback, so
delivered=false; the binding is minted regardless):
{
"binding_id": "145c2779-1759-4378-abf3-c49a49408ede",
"resolver_did": "did:ocss:loopline",
"endpoint_id_label": "ngb14RDrOQ57c9xIMPpKdQtqdLDdent9jiBo375He90",
"profile_url": "/api/v1/enforcement-profiles/ngb14RDrOQ57c9xIMPpKdQtqdLDdent9jiBo375He90",
"connect_receiver": "https://example.com/api/ocss/connect",
"delivered": false,
"http_status": 405,
"state": "sandbox-test-connect:145c2779-1759-4378-abf3-c49a49408ede",
"note": "callback delivery did not receive a 2xx — check webhook_url reachability and that connect_secret matches your gk.config; the binding is minted and profile_url is observable regardless"
}
profile_url with your platform key (fetch profile)
to see the router-signed profile land, and rotate
with the returned binding_id.Body
SANDBOX-ONLY (POST /api/v1/sandbox/test-connect). Runs the PROVIDER side of the EXT-04 §3.2 connect ceremony against the caller's own platform DID + webhook so a cold self-registered platform dev receives a real signed binding with no live counterparty. RFC 9421 signed as the caller. Source: sandboxTestConnectBody in internal/ocsshttp/handler_sandbox_test_connect.go.
The consuming platform's Trust-List DID (the DID you self-registered and sign with).
"did:ocss:my-gatekeeper"
Your POST /api/ocss/connect receiver BASE (the well-known path is appended). Absolute http/https, no query/fragment/credentials. Plain http admitted (sandbox).
"https://my-gatekeeper.example.com"
The HMAC secret you fed into gk.config({ connectSecret }); the §3.6 callback is signed with it so your receiver verifies X-Phosra-Signature. Supplied because the census stores only its digest.
Optional seeded test child ("child:"); defaults to the seeded Mia child.
"child:a11ce0fa-0000-4000-8000-0000000000a1"
Optional §6.3 rotation window in seconds; defaults to 3600.
Response
Ceremony ran. The binding is minted and profile_url is observable even if the webhook leg failed (delivered=false + note).
Result of the sandbox provider ceremony. The binding is minted and profile_url is observable even when the webhook delivery leg fails (delivered=false + note). Source: sandboxTestConnectResp in handler_sandbox_test_connect.go.
The binding UUID returned once by the mint ceremony; pass it to the rotate endpoint.
Echo of platform_did — the DID the binding resolves to (sign the profile poll with it).
The connect-ceremony binding label (unprefixed 43-char base64url). Poll it at profile_url with your platform key. Never log.
Path to poll the router-signed enforcement profile for this binding.
"/api/v1/enforcement-profiles/…"
The full receiver URL the signed §3.6 callback was POSTed to (webhook_url + /api/ocss/connect).
True when your receiver returned 2xx. False (with note) when the webhook leg failed — the binding is minted regardless.
The HTTP status your receiver returned (0 if unreachable).
Opaque sandbox principal ref delivered alongside the label ("sandbox-test-connect:<binding_id>").
Present only when delivered=false — explains the webhook-leg failure.