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.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.201 Created):
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.signing_secret — this is the only time it is ever returned. Store it
now; you will use it to verify every delivery.
Required fields
Required fields
UUID of the family this webhook watches. The authenticated key’s user must be an owner or
parent of this family.
Your HTTPS endpoint. Must be reachable and should return
2xx within 10 seconds
(the delivery HTTP client times out at 10s).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); thedata 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.
| Event | Fires when | Scope |
|---|---|---|
policy.updated | A child’s policy is (re)compiled after a change | Family |
pack.adopted | A community standard (pack) is adopted for a child | Family |
pack.revoked | A community standard is removed from a child | Family |
pack.version.released | A pack publishes a new hashed version | Catalog (all subscribers) |
test | You call the test endpoint | Single 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.
UUID of the child whose policy changed.
UUID of the child policy.
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.
UUID of the child.
UUID of the adopted standard.
Human-readable slug of the standard.
The standard’s version at adoption time.
Release channel —
stable or beta.UUID of this adoption record.
pack.revoked
Fires when a community standard is removed from a child.
UUID of the child.
UUID of the revoked standard.
Slug of the standard (empty if the record was already gone).
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.
UUID of the standard.
The newly released version.
Release channel —
stable or beta.Content hash of the version manifest — verify your mirror against this.
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 JSONPOST with the same envelope and the same three
Phosra headers.
Envelope
Headers
| Header | Example | Purpose |
|---|---|---|
X-Phosra-Signature | t=1720000000,v1=f3863adc…0d11 | HMAC signature — verify it. |
X-Phosra-Event | policy.updated | The event name (mirrors event in the body). |
X-Phosra-Event-ID | 11111111-1111-4111-8111-111111111111 | The event’s stable ID (mirrors event_id) — your idempotency key. |
Content-Type | application/json | Always application/json. |
Verifying signatures
Always verifyX-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-encodedHMAC-SHA256, keyed by yoursigning_secret, over the exact preimage:
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 a2xx, Phosra retries with exponential backoff. The backoff
doubles from 30 seconds and caps at 8 minutes:
| Attempt | Delay before it |
|---|---|
| 1 (initial) | — |
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 2 minutes |
| 5 | 4 minutes |
| 6–10 | 8 minutes (capped) |
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.
Idempotency
Because of retries and network timeouts, Phosra may deliver the same event more than once. Every delivery of a given event carries the sameevent_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.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 atest event at your endpoint to confirm it is reachable and your signature check passes:
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):
405-ing test URL):
Response — each delivery record
The endpoint returns a JSON array of delivery records, newest first. Each record:UUID of this delivery-attempt record. Use it to correlate log lines; it is not the event
identity (that is
payload.event_id).UUID of the webhook this delivery belongs to (matches the
{id} in the path).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.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).true once your endpoint returned a 2xx. false while a delivery is failing, mid-retry, or
dead-lettered.How many delivery attempts have been made so far —
1 on the first try, up to
WebhookMaxAttempts = 10.RFC 3339 timestamp of the next scheduled retry. A concrete future time while mid-retry;
omitempty — absent once the delivery has succeeded or been dead-lettered (there is nothing
left to retry). jq reads the absent field as null.RFC 3339 timestamp of when the delivery record was created — i.e. when the event fired.
Pagination and filtering
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 recentlimitrecords. - No cursor or offset. There is no
offset/page/afterparameter —limitis the only knob. To review deeper history, raiselimit. - 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.
Your HTTPS endpoint. Omitting it sets
url to the empty string "" (verified live) — which
breaks delivery. Always include it.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.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.Re-enable an auto-disabled webhook
When Phosra auto-disables a webhook (3 dead-letters) it setsactive: false. To recover, PUT the full body with active: true — re-supplying url and
events (a bare {"active":true} returns 500):
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).
| Status | Body | Cause |
|---|---|---|
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 onhttp://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.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: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):Fire a delivery
Trigger the Your local server logs the headers and
test event — it travels the exact same signed path as a real event, so
it exercises your tunnel, your signature check, and your ack:✓ 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).Replay a delivery
When a delivery fails — your tunnel was down, your handler500ed, the event was
dead-lettered after 10 attempts — you will want to run that exact payload
through your handler again.
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 hassuccess: 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 storedpayload 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:- Re-enable the webhook if it was auto-disabled, by
PUTtingactive: true(see Update a webhook). - Fire a fresh delivery with
POST /test. Note this sends a newtestevent with a newevent_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
Verify the signature before anything else
Verify the signature before anything else
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.De-duplicate on event_id
De-duplicate on event_id
Retries mean at-least-once delivery. Skip any
event_id (X-Phosra-Event-ID) you have already
processed, backed by a durable store.Ack fast, process async
Ack fast, process async
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.Monitor delivery health
Monitor delivery health
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.HTTPS only
HTTPS only
Webhook URLs must use HTTPS in production.