Idempotency
Networks retry. Phosra makes that safe: a state-changing call carries an idempotency key, and a redelivery of that key never produces a second effect. "Duplicate delivery MUST NOT produce a duplicate consequence" is the §8.1 clause-3 contract, and it holds by construction — the state change, its signed receipt, the audit rail row, and the key are all written in one database transaction, so a duplicate rolls all four back together.
Runnable. The signed worked example below was executed live against
https://phosra-api-sandbox-production.up.railway.app on 2026-07-06 using the
then-published @openchildsafety/ocss signer (v0.1.4). The response bytes are
pasted verbatim as historical evidence. Public integrations should install
current @openchildsafety/[email protected], which preserves this signing behavior.
Where the key lives — there is no Idempotency-Key header
Unlike some APIs, Phosra does not read an Idempotency-Key HTTP header. A write
gets its key one of two ways:
| Kind | The key is… | Used by |
|---|---|---|
| Client-supplied | a field in the request body, idempotency_key | rule writes (POST …/rules), consent attestations, source syncs, alert subscriptions |
| Server-derived | computed deterministically from stable request fields (e.g. (rule_ref, app_did)), so two honest retries collide on purpose | enforcement confirmations, routing manifests, compliance bundles, alert emits |
Either way the key is scoped to its operation (a frozen operation → namespace table), so one operation's replay space can never bleed into another's.
Do not send an Idempotency-Key header expecting dedup — it is ignored. Put
idempotency_key in the JSON body where the endpoint accepts one, and freeze the
whole payload before you attach it (see pitfalls).
The two outcomes
Once a (scope, key) has been applied, redelivering it has exactly two possible
results — decided by whether the payload is byte-identical to the first one:
- 1First application → 201 Created
The write happens. You get
201and the freshly signed receipt. - 2Faithful replay → 200 + OCSS-Replay: original
Same key, byte-identical payload. Nothing runs a second time. You get
200, the headerOCSS-Replay: original, and the byte-identical original receipt. This is a success, not an error. - 3Conflicting replay → 409 replay
Same key, different payload. The key is bound to its first payload and cannot be rebound:
409 Conflict,class: "replay", and nothing is applied. The stored receipt is withheld.
| You sent | Server state | HTTP | Marker |
|---|---|---|---|
New (scope, key) | applied once | 201 Created | — |
| Same key + same payload | unchanged | 200 OK | OCSS-Replay: original |
| Same key + changed payload | unchanged (rejected) | 409 Conflict | class: "replay" |
A faithful replay is success-shaped. It returns the original bytes and never
emits a failed-call receipt. The 200 vs 201 distinction is how you tell a
fresh apply from a replay without diffing bodies — read the status and the
OCSS-Replay header.
Worked example — a server-derived key, live
The sandbox consent-attestation route is a signed (RFC 9421) write. Send the same
signed request twice and the census returns a stable, server-derived
idempotency_key — proof that the operation is deduplicated on a deterministic
identity rather than on wall-clock time.
import { signRequest } from "@openchildsafety/ocss"; // recorded with 0.1.4; public latest: 0.1.5
const BASE = "https://phosra-api-sandbox-production.up.railway.app/api/v1";
const seed = new Uint8Array(
Buffer.from(process.env.OCSS_SANDBOX_TEST_SEED_B64URL!, "base64url"),
);
const keyID = process.env.OCSS_SANDBOX_KEY_ID!;
const target = `${BASE}/sandbox/consent-attestations`;
const bodyText = JSON.stringify({ band: "13_15", consent_scope: "collection_parental_authority" });
async function mint() {
const headers = signRequest({
method: "POST", targetURI: target,
body: new TextEncoder().encode(bodyText),
keyID, seed, created: Math.floor(Date.now() / 1000),
});
headers["Content-Type"] = "application/json";
const res = await fetch(target, { method: "POST", headers, body: bodyText });
return { status: res.status, replay: res.headers.get("ocss-replay"), body: await res.json() };
}
console.log(await mint());
console.log(await mint()); // same derived key both times{
"ok": true,
"app_ref": "did:ocss:loopline",
"target_ref": "child:5ba0d00c-0000-4000-8000-0000000000c1",
"standing_ref": "consent:attestation:sbx-consent:did:ocss:loopline:child:5ba0d00c-0000-4000-8000-0000000000c1",
"idempotency_key": "sbx-consent:did:ocss:loopline:child:5ba0d00c-0000-4000-8000-0000000000c1",
"band": "13_15",
"consent_scope": "collection_parental_authority",
"expiry": "2027-07-06T07:02:30Z"
}The idempotency_key is derived from (caller DID, target child) — no timestamp —
so it is identical on every honest retry. Pass the returned target_ref straight
into POST /enforcement-endpoints to
finish the consent-first mint.
Client-supplied keys
Where an endpoint accepts a client key (rule writes, source syncs), put it in the body and reuse it — with the same payload — on every retry:
BASE=https://phosra-api-sandbox-production.up.railway.app
# Reuse ONE key + the SAME body across retries. First call applies; retries replay.
KEY="rules-2026-07-06-batch-01"
curl -s -X POST "$BASE/api/v1/policies/$POLICY_ID/rules" \
-H "Authorization: Bearer $PHOSRA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"idempotency_key":"'"$KEY"'","rule_category":"content_rating","age_band":"13_15","...":"..."}'On a faithful retry this returns 200 with OCSS-Replay: original and the exact
receipt bytes from the first call.
The one pitfall: a changed payload
A 409 replay almost always means a retried request mutated its body between
attempts — a regenerated timestamp, a fresh nonce, or a reordered array. The key is
frozen to its first payload; a different payload under the same key is rejected.
Freeze the payload before you attach the key. Serialize the body once, keep both the key and those exact bytes, and resend them together on every retry. Do not rebuild the JSON (and never regenerate an inner timestamp) between attempts.
{
"error": "Conflict",
"message": "idempotency key reused with a different payload",
"code": 409,
"class": "replay"
}Retry rules
| Situation | Retry? | How |
|---|---|---|
Network error / timeout / 5xx | Yes | Resend the same key + same payload; a duplicate is absorbed as a faithful replay |
429 | Yes | Wait for reset, then resend the same key + payload |
200 + OCSS-Replay: original | No | Already applied — you have the original receipt |
409 replay | No | You changed the payload; reuse the original bytes, or mint a fresh key for a genuinely new write |