Skip to main content
Webhooks let your application receive real-time HTTP POST notifications when things change in Phosra — a policy is updated, a community standard is adopted, a new pack version is released. Instead of polling, you register an HTTPS endpoint once and Phosra pushes a signed event to it every time something happens. This guide is the complete reference: the event catalog (every event Phosra actually emits, with its exact payload), signature verification you can copy-paste in curl, TypeScript, Python, and Go, the retry and idempotency semantics your handler needs to be correct, and how to test deliveries against localhost and replay a failed delivery while you build.
Every event name, payload field, header, and retry number on this page is taken directly from the Phosra webhook emitter source (internal/service/webhook.go, internal/service/webhook_retry_worker.go) and verified against the live sandbox at https://phosra-api-sandbox-production.up.railway.app. Nothing here is aspirational — if an event is not in the catalog, Phosra does not emit it yet.

Before you start

Webhook management is a developer-key operation, not an end-user one. To create, update, or delete a webhook you need:

A Growth-tier developer key

Webhook management requires the webhook:manage scope, which is only available on the Growth developer tier or above. A free-tier key gets 403 Forbidden.

A family you belong to

A webhook is scoped to one family_id. The authenticated key’s user must be an owner or parent of that family, or the call returns 403 not a member of this family.
Set your key and base URL:
export PHOSRA_API_KEY="phosra_live_…"          # a Growth-tier key with webhook:manage
export PHOSRA_API="https://prodapi.phosra.com" # production
# To develop against the open sandbox, use:
# export PHOSRA_API="https://phosra-api-sandbox-production.up.railway.app"

Sandbox on-ramp (no key required)

The event catalog, the signature scheme, and every management endpoint on this page run unchanged on the open sandbox — so you can build and test the entire webhook loop before you ever provision a production key.
The open sandbox does not enforce the Growth tier and needs no API key. The webhook:manage scope and Growth-tier gate in Before you start apply to production (prodapi.phosra.com). On the sandbox, unauthenticated calls are served as a shared Playground identity — send no Authorization header at all. When you graduate to production, mint a Growth-tier key at dashboard.phosra.com/dashboard/developers/keys and add -H "Authorization: Bearer $PHOSRA_API_KEY" to each call.
Point at the sandbox and mint a family to hang a webhook on — one curl, no key:
export PHOSRA_API="https://phosra-api-sandbox-production.up.railway.app"

# Create a sandbox family. Returns a family_id for the webhook below.
curl -s -X POST "$PHOSRA_API/api/v1/families" \
  -H "Content-Type: application/json" \
  -d '{"name": "Webhook Test Family"}'
Live sandbox response (verbatim, 201 Created):
{
  "id": "1cccbf1e-45a0-4bed-83c6-e0abf7f78eee",
  "name": "Webhook Test Family",
  "created_at": "2026-07-06T12:05:02.184912329Z",
  "updated_at": "2026-07-06T12:05:02.184912329Z"
}
Export that id and the whole loop — register a webhook, send a test delivery, inspect the delivery log, and test against localhost — runs on the sandbox with no key:
export FAMILY_ID="1cccbf1e-45a0-4bed-83c6-e0abf7f78eee"  # the id returned above
The sandbox Playground identity is shared across everyone using the open sandbox, so the family_id you create is your own but the identity that owns it is not private. Never put real personal data in a sandbox family — it exists only to exercise the API.

Register a webhook

Create a subscription for a family. Pass the events you care about — subscribe only to events in the catalog below.
curl -X POST "$PHOSRA_API/api/v1/webhooks" \
  -H "Authorization: Bearer $PHOSRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "family_id": "550e8400-e29b-41d4-a716-446655440000",
    "url": "https://yourapp.com/webhooks/phosra",
    "events": [
      "policy.updated",
      "pack.adopted",
      "pack.revoked",
      "pack.version.released"
    ]
  }'
The response includes a signing_secret — this is the only time it is ever returned. Store it now; you will use it to verify every delivery.
{
  "id": "9b7d3c1e-6f2a-4a1b-8c9d-2e4f6a8b0c2d",
  "family_id": "550e8400-e29b-41d4-a716-446655440000",
  "url": "https://yourapp.com/webhooks/phosra",
  "events": ["policy.updated", "pack.adopted", "pack.revoked", "pack.version.released"],
  "active": true,
  "signing_secret": "3f9a1c7e2b8d4f60a1c5e9b7d3f1a8c6e4b2d0f8a6c4e2b0d8f6a4c2e0b8d6f4",
  "created_at": "2026-07-05T14:30:00Z"
}
signing_secret is returned once, on create. Subsequent GET calls on the webhook omit it. If you lose it, delete the webhook and create a new one.
family_id
string
required
UUID of the family this webhook watches. The authenticated key’s user must be an owner or parent of this family.
url
string
required
Your HTTPS endpoint. Must be reachable and should return 2xx within 10 seconds (the delivery HTTP client times out at 10s).
events
string[]
required
The list of event names to deliver. An event not in this list is never sent to this endpoint.

Event catalog

Phosra emits a small, verified set of events today. Each event is delivered as a JSON envelope (see Delivery format); the data object differs per event. The table below is the exhaustive list — Phosra does not currently emit enforcement.*, child.*, compliance.*, or device.* events, so do not build against them.
EventFires whenScope
policy.updatedA child’s policy is (re)compiled after a changeFamily
pack.adoptedA community standard (pack) is adopted for a childFamily
pack.revokedA community standard is removed from a childFamily
pack.version.releasedA pack publishes a new hashed versionCatalog (all subscribers)
testYou call the test endpointSingle delivery

policy.updated

Fires when a child’s policy is recompiled — a rule is added, changed, or removed and the new policy version is committed.
data.child_id
string
UUID of the child whose policy changed.
data.policy_id
string
UUID of the child policy.
data.version
integer
The new, monotonically increasing policy version.
{
  "event_id": "11111111-1111-4111-8111-111111111111",
  "event": "policy.updated",
  "timestamp": "2026-07-05T14:30:00Z",
  "data": {
    "child_id": "770e8400-e29b-41d4-a716-446655440002",
    "policy_id": "660e8400-e29b-41d4-a716-446655440001",
    "version": 7
  }
}

pack.adopted

Fires when a community standard (a shareable “pack” of rules) is adopted for a child. Partner apps typically re-fetch the child’s effective policy on receipt.
data.child_id
string
UUID of the child.
data.standard_id
string
UUID of the adopted standard.
data.standard_slug
string
Human-readable slug of the standard.
data.version
string
The standard’s version at adoption time.
data.channel
string
Release channel — stable or beta.
data.adoption_id
string
UUID of this adoption record.
{
  "event_id": "22222222-2222-4222-8222-222222222222",
  "event": "pack.adopted",
  "timestamp": "2026-07-05T14:31:12Z",
  "data": {
    "child_id": "770e8400-e29b-41d4-a716-446655440002",
    "standard_id": "aa0e8400-e29b-41d4-a716-4466554400aa",
    "standard_slug": "under-13-baseline",
    "version": "1.4.0",
    "channel": "stable",
    "adoption_id": "bb0e8400-e29b-41d4-a716-4466554400bb"
  }
}

pack.revoked

Fires when a community standard is removed from a child.
data.child_id
string
UUID of the child.
data.standard_id
string
UUID of the revoked standard.
data.standard_slug
string
Slug of the standard (empty if the record was already gone).
data.version
string
Version at revocation time (empty if unknown).
{
  "event_id": "33333333-3333-4333-8333-333333333333",
  "event": "pack.revoked",
  "timestamp": "2026-07-05T14:32:40Z",
  "data": {
    "child_id": "770e8400-e29b-41d4-a716-446655440002",
    "standard_id": "aa0e8400-e29b-41d4-a716-4466554400aa",
    "standard_slug": "under-13-baseline",
    "version": "1.4.0"
  }
}

pack.version.released

A catalog-level event: fires when a pack publishes a new version with a verifiable manifest hash. Unlike the family-scoped events above, this is broadcast to every webhook subscribed to pack.version.released (it is not tied to one family). Use it to keep a local mirror of pack versions in sync.
data.standard_id
string
UUID of the standard.
data.version
string
The newly released version.
data.channel
string
Release channel — stable or beta.
data.manifest_hash
string
Content hash of the version manifest — verify your mirror against this.
data.previous_version
string
The version this one supersedes (empty for the first release).
{
  "event_id": "44444444-4444-4444-8444-444444444444",
  "event": "pack.version.released",
  "timestamp": "2026-07-05T14:35:00Z",
  "data": {
    "standard_id": "aa0e8400-e29b-41d4-a716-4466554400aa",
    "version": "1.5.0",
    "channel": "stable",
    "manifest_hash": "sha256:9f2b…",
    "previous_version": "1.4.0"
  }
}

test

Emitted only when you call the test endpoint. Carries a fixed payload so you can wire up and verify your handler before any real event fires.
{
  "event_id": "55555555-5555-4555-8555-555555555555",
  "event": "test",
  "timestamp": "2026-07-05T14:29:00Z",
  "data": { "message": "This is a test webhook delivery" }
}

Delivery format

Every delivery — real or test — is a JSON POST with the same envelope and the same three Phosra headers.

Envelope

{
  "event_id": "<uuid>",       // stable ID for this event; use it to de-duplicate
  "event": "<event.name>",    // one of the catalog names above
  "timestamp": "<RFC 3339>",  // when Phosra generated the event (UTC)
  "data": { }                 // event-specific, per the catalog
}

Headers

HeaderExamplePurpose
X-Phosra-Signaturet=1720000000,v1=f3863adc…0d11HMAC signature — verify it.
X-Phosra-Eventpolicy.updatedThe event name (mirrors event in the body).
X-Phosra-Event-ID11111111-1111-4111-8111-111111111111The event’s stable ID (mirrors event_id) — your idempotency key.
Content-Typeapplication/jsonAlways application/json.

Verifying signatures

Always verify X-Phosra-Signature before trusting a payload. It is the only proof that a delivery came from Phosra and was not tampered with in transit.

Signature scheme

X-Phosra-Signature: t=<unix_timestamp>,v1=<signature>
  • t — Unix timestamp (seconds) when the delivery was signed. Reject deliveries older than a few minutes to defend against replays.
  • v1 — hex-encoded HMAC-SHA256, keyed by your signing_secret, over the exact preimage:
<t> + "." + <raw request body bytes>
The preimage is the timestamp, a literal period, then the raw bytes of the request body — before any JSON parsing. Parsing and re-serializing changes the bytes and breaks the HMAC. The v1= prefix versions the algorithm so it can evolve without renaming the header.
# Given the raw body in body.json and the header value in $SIG_HEADER:
T=$(printf '%s' "$SIG_HEADER" | sed -E 's/^t=([0-9]+),.*/\1/')
V1=$(printf '%s' "$SIG_HEADER" | sed -E 's/.*,v1=//')

# Recompute: HMAC-SHA256( secret, "<t>." + rawBody )
EXPECTED=$( { printf '%s.' "$T"; cat body.json; } \
  | openssl dgst -sha256 -hmac "$PHOSRA_WEBHOOK_SECRET" -r \
  | cut -d' ' -f1 )

[ "$V1" = "$EXPECTED" ] && echo "signature OK" || echo "signature INVALID"
All four snippets are cross-checked to produce the identical HMAC for the same secret, timestamp, and body. Always compare in constant time (timingSafeEqual / hmac.compare_digest / hmac.Equal) — a plain == leaks timing information about where the strings differ.

Delivery, retries, and idempotency

Retry schedule

If your endpoint does not return a 2xx, Phosra retries with exponential backoff. The backoff doubles from 30 seconds and caps at 8 minutes:
AttemptDelay before it
1 (initial)
230 seconds
31 minute
42 minutes
54 minutes
6–108 minutes (capped)
Phosra makes up to 10 total attempts (WebhookMaxAttempts = 10). After the 10th failure the delivery is dead-lettered — it stops retrying and next_retry_at is cleared. You can inspect the state of any delivery via the deliveries endpoint, where a dead-lettered delivery has success: false and a null next_retry_at.
Auto-disable. If an endpoint accumulates 3 dead-lettered deliveries, Phosra automatically disables the webhook (active: false) to stop hammering a broken endpoint. Re-enable it with a PUT once your endpoint is healthy again.

Idempotency

Because of retries and network timeouts, Phosra may deliver the same event more than once. Every delivery of a given event carries the same event_id — in both the payload and the X-Phosra-Event-ID header. De-duplicate on it: record the event_ids you have processed and skip any you have seen.
The idempotency key is event_id (payload field) / X-Phosra-Event-ID (header). There is no separate idempotency_key field.
const processed = new Set<string>() // in production: a durable store (Redis/DB) with a TTL

app.post("/webhooks/phosra", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8")
  const sig = req.headers["x-phosra-signature"] as string | undefined

  // 1. Verify first.
  if (!verifyPhosraWebhook(raw, sig ?? null, process.env.PHOSRA_WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: "invalid signature" })
  }

  // 2. De-duplicate on event_id.
  const { event_id, event, data } = JSON.parse(raw)
  if (processed.has(event_id)) {
    return res.sendStatus(200) // already handled — ack and move on
  }
  processed.add(event_id)

  // 3. Handle, then ack within 10 seconds (the delivery client times out at 10s).
  res.sendStatus(200)
})
Persist processed event_ids to a durable store with a TTL comfortably longer than the retry window (a few hours covers all 10 attempts).

Managing webhooks

Send a test delivery

Fire a test event at your endpoint to confirm it is reachable and your signature check passes:
curl -X POST "$PHOSRA_API/api/v1/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer $PHOSRA_API_KEY"
The response is the delivery record for the attempt Phosra just made — the same shape the deliveries endpoint returns. Live sandbox response (verbatim — the https://example.com test URL 405s a POST, so success is false and a retry is scheduled):
{
  "id": "8e21aa2b-27b1-4d02-bbd3-990787ecbd47",
  "webhook_id": "9f9a8235-c00d-43f1-8983-e65955034d6e",
  "event": "test",
  "payload": {
    "event_id": "8e21aa2b-27b1-4d02-bbd3-990787ecbd47",
    "event": "test",
    "timestamp": "2026-07-06T12:17:49Z",
    "data": { "message": "This is a test webhook delivery" }
  },
  "response_code": 405,
  "success": false,
  "attempts": 1,
  "next_retry_at": "2026-07-06T12:18:19.138878215Z",
  "created_at": "2026-07-06T12:17:49.138879645Z"
}

Inspect delivery history

GET /api/v1/webhooks/{id}/deliveries lists recent delivery attempts for a webhook — including response codes, attempt counts, and the next_retry_at of anything mid-retry. On the sandbox this runs with no key (the whole record is served to the Playground identity):
curl -s "$PHOSRA_API/api/v1/webhooks/$WEBHOOK_ID/deliveries?limit=20"
Live sandbox response (verbatim — one dead-on-arrival delivery to a 405-ing test URL):
[
  {
    "id": "9b3ec05b-c0d5-43fe-a2d5-b056e4832a4d",
    "webhook_id": "a3c22514-80e6-4bc0-af81-17a0ace0bf43",
    "event": "test",
    "payload": {
      "event_id": "9b3ec05b-c0d5-43fe-a2d5-b056e4832a4d",
      "event": "test",
      "timestamp": "2026-07-06T12:05:02Z",
      "data": { "message": "This is a test webhook delivery" }
    },
    "response_code": 405,
    "success": false,
    "attempts": 1,
    "next_retry_at": "2026-07-06T12:05:32.523481Z",
    "created_at": "2026-07-06T12:05:02.523499Z"
  }
]

Response — each delivery record

The endpoint returns a JSON array of delivery records, newest first. Each record:
id
string
UUID of this delivery-attempt record. Use it to correlate log lines; it is not the event identity (that is payload.event_id).
webhook_id
string
UUID of the webhook this delivery belongs to (matches the {id} in the path).
event
string
The event name — a catalog name (e.g. policy.updated) or test. Mirrors payload.event.
payload
object
The full signed envelope Phosra POSTed (or would POST): event_id, event, timestamp, and the event-specific data. This is the exact body that was signed, so it is the basis for client-driven replay.
response_code
integer
HTTP status your endpoint returned on the most recent attempt (e.g. 200, 405, 502). omitempty — absent if no attempt has completed yet (still queued).
success
boolean
true once your endpoint returned a 2xx. false while a delivery is failing, mid-retry, or dead-lettered.
attempts
integer
How many delivery attempts have been made so far — 1 on the first try, up to WebhookMaxAttempts = 10.
next_retry_at
string
RFC 3339 timestamp of the next scheduled retry. A concrete future time while mid-retry; omitemptyabsent once the delivery has succeeded or been dead-lettered (there is nothing left to retry). jq reads the absent field as null.
created_at
string
RFC 3339 timestamp of when the delivery record was created — i.e. when the event fired.

Pagination and filtering

limit
integer
default:"50"
Maximum number of records to return, newest first. Any positive integer is honored; a missing, non-numeric, or ≤ 0 value falls back to 50.
  • Ordering is fixed: newest first (ORDER BY created_at DESC). The endpoint always returns the most recent limit records.
  • No cursor or offset. There is no offset / page / after parameter — limit is the only knob. To review deeper history, raise limit.
  • No server-side filtering. There is no success=, event=, or date filter. Filter client-side — e.g. to find failures, jq '[.[] | select(.success == false)]' (this is exactly what the replay flow does).

List and delete

# List every webhook on a family
curl "$PHOSRA_API/api/v1/families/$FAMILY_ID/webhooks" \
  -H "Authorization: Bearer $PHOSRA_API_KEY"

# Delete a webhook (owner/parent only)
curl -X DELETE "$PHOSRA_API/api/v1/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $PHOSRA_API_KEY"

Update a webhook (PUT)

PUT /api/v1/webhooks/{id} replaces the whole editable body. It is not a PATCH: any mutable field you omit is written back as its zero value, not left untouched. Always send the complete url + events + active triplet on every update.
url
string
required
Your HTTPS endpoint. Omitting it sets url to the empty string "" (verified live) — which breaks delivery. Always include it.
events
string[]
required
The full subscription list. Must be present as a JSON array — even []. Omitting the field entirely serializes to a null slice and the update fails with 500 Internal Server Error (verified live). Send the events you want; the previous list is replaced, not merged.
active
boolean
default:"false"
Enable/disable delivery. Omitting it sets active to false (the JSON boolean zero value), silently disabling the webhook (verified live). Always send true to keep it live.
# Correct: full body. Replaces url + events + active atomically.
curl -X PUT "$PHOSRA_API/api/v1/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $PHOSRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://yourapp.com/webhooks/phosra","events":["policy.updated"],"active":true}'
Two omission traps, both verified against the live sandbox:
  • PUT {"active":true} alone → 500 (the omitted events becomes a null slice).
  • PUT {"url":"…","events":["…"]} (no active) → 200, but active is now false — you just disabled your own webhook.
There is no partial update. Read the webhook first (GET), change one field, and PUT the whole object back.

Re-enable an auto-disabled webhook

When Phosra auto-disables a webhook (3 dead-letters) it sets active: false. To recover, PUT the full body with active: true — re-supplying url and events (a bare {"active":true} returns 500):
# 1. Read the current webhook to recover its url + events (signing_secret is NOT returned here).
curl -s "$PHOSRA_API/api/v1/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $PHOSRA_API_KEY"

# 2. PUT the FULL body back with active:true.
curl -X PUT "$PHOSRA_API/api/v1/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $PHOSRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://yourapp.com/webhooks/phosra","events":["policy.updated","pack.adopted"],"active":true}'
Live sandbox response (verbatim 200 OK — note active flips back to true and updated_at advances; signing_secret is not re-issued):
{
  "id": "9f9a8235-c00d-43f1-8983-e65955034d6e",
  "family_id": "ebbfa009-1ca7-4f58-885f-ce2c41ddab24",
  "url": "https://yourapp.com/webhooks/phosra",
  "events": ["policy.updated", "pack.adopted"],
  "active": true,
  "created_at": "2026-07-06T12:17:33.4281Z",
  "updated_at": "2026-07-06T12:17:33.97721639Z"
}

Error responses

The webhook endpoints return structured JSON errors — {"error":<reason>,"message":<detail>,"code":<status>}. Bodies marked (live) were captured verbatim from the sandbox; the 401/403/429 rows are the production developer-key responses (the open sandbox has no key/tier gate — see the note below).
StatusBodyCause
400{"error":"Bad Request","message":"invalid webhook ID","code":400} (live)The {id} path segment is not a valid UUID.
400{"error":"Bad Request","message":"invalid family_id","code":400} (live)family_id in a create body is missing or not a valid UUID.
401{"error":"Unauthorized","message":"invalid API key","code":401}Production only. No / malformed / revoked / expired phosra_* key. Also "API key has been revoked" / "API key has expired".
403{"error":"Forbidden","message":"not a member of this family","code":403}The authenticated user is not an owner/parent of family_id.
403{"error":"Forbidden","message":"insufficient tier","code":403}Production only. The key is below the Growth tier required for webhook:manage.
404{"error":"Not Found","message":"webhook not found","code":404} (live)No webhook with that ID is visible to the caller.
429{"error":"Too Many Requests","message":"rate limit exceeded","code":429}Rate limit exceeded. See below.
500{"error":"Internal Server Error","message":"internal server error","code":500} (live)A PUT body that omits events (the field becomes a null slice). Always send events as an array — see Update a webhook.
No 401 on the open sandbox. Because the sandbox serves unauthenticated calls as the shared Playground identity, a missing key never yields 401 there — you get straight to 400/404 business-logic errors. The 401 (missing/invalid key) and the insufficient tier 403 only appear on production (prodapi.phosra.com), where a real Growth-tier key is required.
Rate limiting. The open sandbox throttles 100 requests/minute per IP; every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers, and a 429 adds a Retry-After. Production keys are limited per-org at your tier’s rate_limit_rpm and return the JSON 429 body above. Back off until the X-RateLimit-Reset epoch.

Test webhooks locally

Your handler runs on http://localhost while you build it, but Phosra can only POST to a public HTTPS URL. The fix is the same one every webhook provider uses: put a public tunnel in front of your local server, register the tunnel URL as a webhook, and fire a delivery at it.
No phosra listen. Unlike Stripe’s stripe listen --forward-to, the Phosra CLI does not bundle a tunnel or a delivery-forwarding command. Use any tunnel you already trust (cloudflared, ngrok, tailscale funnel); the test endpoint is what actually drives a delivery through it.
1

Run your handler locally

Start the receiver on a local port. This one prints the three Phosra headers, verifies the signature with the verifyPhosraWebhook helper from above, and acks 200:
import express from "express"
// verifyPhosraWebhook is the helper from the "Verifying signatures" section above.

const app = express()

app.post("/webhooks/phosra", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8")
  const sig = req.headers["x-phosra-signature"] as string | undefined

  console.log("event   :", req.headers["x-phosra-event"])
  console.log("event-id:", req.headers["x-phosra-event-id"])
  console.log("signature:", sig)

  if (!verifyPhosraWebhook(raw, sig ?? null, process.env.PHOSRA_WEBHOOK_SECRET!)) {
    console.error("  ✗ signature INVALID")
    return res.status(401).json({ error: "invalid signature" })
  }
  console.log("  ✓ signature OK", raw)
  res.sendStatus(200)
})

app.listen(4242, () => console.log("listening on http://localhost:4242"))
2

Expose it with a tunnel

Point a public HTTPS tunnel at that port and copy the URL it prints:
# Cloudflare Tunnel (no account needed for a quick throwaway URL)
cloudflared tunnel --url http://localhost:4242
#  → https://random-words-1234.trycloudflare.com

# …or ngrok
ngrok http 4242
#  → Forwarding  https://a1b2c3d4.ngrok-free.app -> http://localhost:4242
3

Register the tunnel URL as a webhook

Create a webhook whose url is the tunnel URL plus your handler path, and capture the signing_secret it returns once (see Register a webhook):
export TUNNEL="https://random-words-1234.trycloudflare.com"

curl -sX POST "$PHOSRA_API/api/v1/webhooks" \
  -H "Authorization: Bearer $PHOSRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"family_id\": \"$FAMILY_ID\",
    \"url\": \"$TUNNEL/webhooks/phosra\",
    \"events\": [\"policy.updated\", \"pack.adopted\"]
  }"
# → { "id": "…", "signing_secret": "…", … }   ← export this as PHOSRA_WEBHOOK_SECRET

export WEBHOOK_ID="…"           # id from the response
export PHOSRA_WEBHOOK_SECRET="…" # signing_secret from the response
4

Fire a delivery

Trigger the test event — it travels the exact same signed path as a real event, so it exercises your tunnel, your signature check, and your ack:
curl -sX POST "$PHOSRA_API/api/v1/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer $PHOSRA_API_KEY"
Your local server logs the headers and ✓ signature OK. To exercise a real event instead of test, perform the underlying action for an event you subscribed to (e.g. adopt a pack for a child to fire pack.adopted).
5

Confirm from the delivery log

Every attempt — test or real, delivered or failed — is recorded. Read it back to see the response code your local server returned:
curl -s "$PHOSRA_API/api/v1/webhooks/$WEBHOOK_ID/deliveries?limit=5" \
  -H "Authorization: Bearer $PHOSRA_API_KEY"
Tunnel URLs are ephemeral — a new cloudflared/ngrok run prints a new hostname. When it changes, PUT the webhook’s url (see Update a webhook) rather than creating a second webhook, so you keep one signing_secret and one delivery history.

Replay a delivery

When a delivery fails — your tunnel was down, your handler 500ed, the event was dead-lettered after 10 attempts — you will want to run that exact payload through your handler again.
There is no server-side redeliver endpoint. Phosra does not expose a POST /webhooks/{id}/deliveries/{delivery_id}/redeliver (or any “resend” button). The only server-driven re-sends are the automatic retry schedule; once a delivery is dead-lettered, Phosra will not fire it again. Replay is therefore client-driven: you read the stored payload back from the deliveries endpoint and re-POST it yourself. This is verified against the live sandbox — the routes on a webhook are exactly GET /, PUT /, DELETE /, POST /test, and GET /deliveries; there is no redeliver route.
The one thing that makes this work: GET /deliveries returns the full event envelope in each record’s payload field. That is the same body Phosra originally signed and sent, so you can reproduce the delivery byte-for-byte.

1. Find the failed delivery

A dead-lettered delivery has success: false, attempts: 10, and no next_retry_at (the field is omitempty, so once it is cleared it drops off the wire entirely — jq reads the absent field as null). A delivery still mid-retry carries a future next_retry_at. Pull the most recent failure and its payload with jq:
# The whole failed/dead-lettered delivery record
curl -s "$PHOSRA_API/api/v1/webhooks/$WEBHOOK_ID/deliveries?limit=50" \
  -H "Authorization: Bearer $PHOSRA_API_KEY" \
  | jq '[.[] | select(.success == false)][0]'
{
  "id": "7c9a1b2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d",
  "webhook_id": "9b7d3c1e-6f2a-4a1b-8c9d-2e4f6a8b0c2d",
  "event": "policy.updated",
  "payload": {
    "event_id": "11111111-1111-4111-8111-111111111111",
    "event": "policy.updated",
    "timestamp": "2026-07-05T14:30:00Z",
    "data": { "child_id": "770e8400-e29b-41d4-a716-446655440002", "policy_id": "660e8400-e29b-41d4-a716-446655440001", "version": 7 }
  },
  "response_code": 502,
  "success": false,
  "attempts": 10,
  "created_at": "2026-07-05T14:30:00Z"
}
next_retry_at and response_code are omitempty — a dead-lettered delivery has cleared its next_retry_at, so it is absent from the JSON above (not "next_retry_at": null). jq still treats the missing field as null, so select(.next_retry_at == null) correctly matches dead-lettered deliveries; a mid-retry delivery has a concrete future timestamp instead.

2. Re-sign the stored payload and POST it to your endpoint

Sign the exact bytes of the stored payload with your signing_secret and the signature scheme (t=<now>,v1=<hmac>), then POST to your handler. Because the bytes are identical to the original, your handler’s signature check passes and your idempotency logic sees the same event_id — exactly what you want to test.
# Extract the raw payload bytes of the first failed delivery into body.json.
curl -s "$PHOSRA_API/api/v1/webhooks/$WEBHOOK_ID/deliveries?limit=50" \
  -H "Authorization: Bearer $PHOSRA_API_KEY" \
  | jq -c '[.[] | select(.success == false)][0].payload' > body.json

# Sign "<t>." + rawBody with the same HMAC the receiver verifies.
T=$(date +%s)
SIG=$( { printf '%s.' "$T"; cat body.json; } \
  | openssl dgst -sha256 -hmac "$PHOSRA_WEBHOOK_SECRET" -r | cut -d' ' -f1 )
EVENT=$(jq -r '.event' body.json)
EVENT_ID=$(jq -r '.event_id' body.json)

# Replay it straight at your local handler (through the tunnel, or localhost directly).
curl -sX POST "http://localhost:4242/webhooks/phosra" \
  -H "Content-Type: application/json" \
  -H "X-Phosra-Signature: t=$T,v1=$SIG" \
  -H "X-Phosra-Event: $EVENT" \
  -H "X-Phosra-Event-ID: $EVENT_ID" \
  --data-binary @body.json
The t=<now> timestamp is deliberately fresh so the replay survives the receiver’s replay window (the MAX_AGE_SECONDS / maxAge check in the verify helpers). The event_id inside the payload is unchanged, so your handler’s de-duplication still recognises it as the same event.

If you want Phosra to deliver again (not replay locally)

Client-side replay tests your handler. If instead you want a fresh, Phosra-signed delivery on the wire — for example after fixing an endpoint that Phosra auto-disabled following 3 dead-letters — do this:
  1. Re-enable the webhook if it was auto-disabled, by PUTting active: true (see Update a webhook).
  2. Fire a fresh delivery with POST /test. Note this sends a new test event with a new event_id — it confirms reachability but is not a replay of the original event. To reproduce a specific past event end-to-end, use the client-driven replay above.

Best practices

Reject (401) any delivery with a missing or invalid X-Phosra-Signature before reading the payload. Read the raw body bytes for the HMAC — never re-serialize first.
Retries mean at-least-once delivery. Skip any event_id (X-Phosra-Event-ID) you have already processed, backed by a durable store.
Return 2xx within the 10-second delivery timeout. If handling is slow, enqueue the event and ack immediately — a slow 200 still counts against your window and can trigger retries.
Poll the deliveries endpoint for failures. Remember that 3 dead-lettered deliveries auto-disable the endpoint — watch for it and re-enable via PUT once healthy.
Webhook URLs must use HTTPS in production.