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:

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:
Live sandbox response (verbatim, 201 Created):
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:
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.
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.
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.
string
required
UUID of the family this webhook watches. The authenticated key’s user must be an owner or parent of this family.
string
required
Your HTTPS endpoint. Must be reachable and should return 2xx within 10 seconds (the delivery HTTP client times out at 10s).
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.

policy.updated

Fires when a child’s policy is recompiled — a rule is added, changed, or removed and the new policy version is committed.
string
UUID of the child whose policy changed.
string
UUID of the child policy.
integer
The new, monotonically increasing policy version.

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.
string
UUID of the child.
string
UUID of the adopted standard.
string
Human-readable slug of the standard.
string
The standard’s version at adoption time.
string
Release channel — stable or beta.
string
UUID of this adoption record.

pack.revoked

Fires when a community standard is removed from a child.
string
UUID of the child.
string
UUID of the revoked standard.
string
Slug of the standard (empty if the record was already gone).
string
Version at revocation time (empty if unknown).

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.
string
UUID of the standard.
string
The newly released version.
string
Release channel — stable or beta.
string
Content hash of the version manifest — verify your mirror against this.
string
The version this one supersedes (empty for the first release).

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.

Delivery format

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

Envelope

Headers

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

  • 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:
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.
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: 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.
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:
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):

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):
Live sandbox response (verbatim — one dead-on-arrival delivery to a 405-ing test URL):

Response — each delivery record

The endpoint returns a JSON array of delivery records, newest first. Each record:
string
UUID of this delivery-attempt record. Use it to correlate log lines; it is not the event identity (that is payload.event_id).
string
UUID of the webhook this delivery belongs to (matches the {id} in the path).
string
The event name — a catalog name (e.g. policy.updated) or test. Mirrors payload.event.
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.
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).
boolean
true once your endpoint returned a 2xx. false while a delivery is failing, mid-retry, or dead-lettered.
integer
How many delivery attempts have been made so far — 1 on the first try, up to WebhookMaxAttempts = 10.
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.
string
RFC 3339 timestamp of when the delivery record was created — i.e. when the event fired.

Pagination and filtering

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

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.
string
required
Your HTTPS endpoint. Omitting it sets url to the empty string "" (verified live) — which breaks delivery. Always include it.
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.
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.
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):
Live sandbox response (verbatim 200 OK — note active flips back to true and updated_at advances; signing_secret is not re-issued):

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).
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:
2

Expose it with a tunnel

Point a public HTTPS tunnel at that port and copy the URL it prints:
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):
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:
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:
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:
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.
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.