> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phosra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive signed, real-time notifications when policies and standards change — with a verified event catalog, copy-paste signature verification in four languages, and documented retry and idempotency semantics.

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](#event-catalog)** (every event Phosra
actually emits, with its exact payload), **[signature verification](#verifying-signatures)** you can
copy-paste in curl, TypeScript, Python, and Go, the **[retry and idempotency
semantics](#delivery-retries-and-idempotency)** your handler needs to be correct, and how to
**[test deliveries against localhost](#test-webhooks-locally)** and **[replay a failed
delivery](#replay-a-delivery)** while you build.

<Info>
  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](#event-catalog), Phosra does not emit it yet.
</Info>

## Before you start

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

<CardGroup cols={2}>
  <Card title="A Growth-tier developer key" icon="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`.
  </Card>

  <Card title="A family you belong to" icon="users">
    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`.
  </Card>
</CardGroup>

Set your key and base URL:

```bash theme={null}
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](#event-catalog), the [signature scheme](#verifying-signatures), 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.

<Info>
  **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](#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](https://dashboard.phosra.com/dashboard/developers/keys)**
  and add `-H "Authorization: Bearer $PHOSRA_API_KEY"` to each call.
</Info>

Point at the sandbox and mint a family to hang a webhook on — one curl, no key:

```bash theme={null}
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`):

```json theme={null}
{
  "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](#register-a-webhook), [send a test
delivery](#send-a-test-delivery), [inspect the delivery log](#inspect-delivery-history), and
[test against localhost](#test-webhooks-locally) — runs on the sandbox with **no key**:

```bash theme={null}
export FAMILY_ID="1cccbf1e-45a0-4bed-83c6-e0abf7f78eee"  # the id returned above
```

<Note>
  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.
</Note>

## Register a webhook

Create a subscription for a family. Pass the events you care about — subscribe **only** to events
in the [catalog below](#event-catalog).

```bash theme={null}
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](#verifying-signatures).

```json theme={null}
{
  "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"
}
```

<Warning>
  `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.
</Warning>

<AccordionGroup>
  <Accordion title="Required fields">
    <ParamField body="family_id" type="string" required>
      UUID of the family this webhook watches. The authenticated key's user must be an owner or
      parent of this family.
    </ParamField>

    <ParamField body="url" type="string" required>
      Your HTTPS endpoint. Must be reachable and should return `2xx` within 10 seconds
      (the delivery HTTP client times out at 10s).
    </ParamField>

    <ParamField body="events" type="string[]" required>
      The list of [event names](#event-catalog) to deliver. An event not in this list is never
      sent to this endpoint.
    </ParamField>
  </Accordion>
</AccordionGroup>

## Event catalog

Phosra emits a **small, verified** set of events today. Each event is delivered as a JSON envelope
(see [Delivery format](#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.

| Event                                             | Fires when                                          | Scope                     |
| ------------------------------------------------- | --------------------------------------------------- | ------------------------- |
| [`policy.updated`](#policy-updated)               | A child's policy is (re)compiled after a change     | Family                    |
| [`pack.adopted`](#pack-adopted)                   | A community standard (pack) is adopted for a child  | Family                    |
| [`pack.revoked`](#pack-revoked)                   | A community standard is removed from a child        | Family                    |
| [`pack.version.released`](#pack-version-released) | A pack publishes a new hashed version               | Catalog (all subscribers) |
| [`test`](#test)                                   | You call the [test endpoint](#send-a-test-delivery) | 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.

<ResponseField name="data.child_id" type="string">UUID of the child whose policy changed.</ResponseField>
<ResponseField name="data.policy_id" type="string">UUID of the child policy.</ResponseField>
<ResponseField name="data.version" type="integer">The new, monotonically increasing policy version.</ResponseField>

```json theme={null}
{
  "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.

<ResponseField name="data.child_id" type="string">UUID of the child.</ResponseField>
<ResponseField name="data.standard_id" type="string">UUID of the adopted standard.</ResponseField>
<ResponseField name="data.standard_slug" type="string">Human-readable slug of the standard.</ResponseField>
<ResponseField name="data.version" type="string">The standard's version at adoption time.</ResponseField>
<ResponseField name="data.channel" type="string">Release channel — `stable` or `beta`.</ResponseField>
<ResponseField name="data.adoption_id" type="string">UUID of this adoption record.</ResponseField>

```json theme={null}
{
  "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.

<ResponseField name="data.child_id" type="string">UUID of the child.</ResponseField>
<ResponseField name="data.standard_id" type="string">UUID of the revoked standard.</ResponseField>
<ResponseField name="data.standard_slug" type="string">Slug of the standard (empty if the record was already gone).</ResponseField>
<ResponseField name="data.version" type="string">Version at revocation time (empty if unknown).</ResponseField>

```json theme={null}
{
  "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.

<ResponseField name="data.standard_id" type="string">UUID of the standard.</ResponseField>
<ResponseField name="data.version" type="string">The newly released version.</ResponseField>
<ResponseField name="data.channel" type="string">Release channel — `stable` or `beta`.</ResponseField>
<ResponseField name="data.manifest_hash" type="string">Content hash of the version manifest — verify your mirror against this.</ResponseField>
<ResponseField name="data.previous_version" type="string">The version this one supersedes (empty for the first release).</ResponseField>

```json theme={null}
{
  "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](#send-a-test-delivery). Carries a fixed payload so
you can wire up and verify your handler before any real event fires.

```json theme={null}
{
  "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

```json theme={null}
{
  "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

| Header               | Example                                | Purpose                                                                                                 |
| -------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `X-Phosra-Signature` | `t=1720000000,v1=f3863adc…0d11`        | HMAC signature — [verify it](#verifying-signatures).                                                    |
| `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](#delivery-retries-and-idempotency). |
| `Content-Type`       | `application/json`                     | Always `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.

<CodeGroup>
  ```bash curl / openssl theme={null}
  # 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"
  ```

  ```ts TypeScript (Node) theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto"

  const MAX_AGE_SECONDS = 300 // reject deliveries older than 5 minutes

  export function verifyPhosraWebhook(
    rawBody: string,
    sigHeader: string | null,
    webhookSecret: string,
  ): boolean {
    if (!sigHeader) return false

    // Parse "t=<timestamp>,v1=<signature>"
    const parts = Object.fromEntries(
      sigHeader.split(",").map((p) => p.split("=") as [string, string]),
    )
    const t = parts.t
    const v1 = parts.v1
    if (!t || !v1) return false

    const timestamp = Number.parseInt(t, 10)
    if (Number.isNaN(timestamp)) return false
    if (Math.floor(Date.now() / 1000) - timestamp > MAX_AGE_SECONDS) return false

    const expected = createHmac("sha256", webhookSecret)
      .update(`${t}.${rawBody}`)
      .digest("hex")

    if (v1.length !== expected.length) return false
    return timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
  }

  // Express — capture the RAW body so the HMAC matches.
  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
      if (!verifyPhosraWebhook(raw, sig ?? null, process.env.PHOSRA_WEBHOOK_SECRET!)) {
        return res.status(401).json({ error: "invalid signature" })
      }
      const event = JSON.parse(raw)
      // … handle event, then ack fast …
      res.sendStatus(200)
    },
  )
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time


  def verify_phosra_webhook(
      raw_body: bytes,
      sig_header: str | None,
      webhook_secret: str,
      max_age_seconds: int = 300,
  ) -> bool:
      if not sig_header:
          return False

      parts = dict(
          p.split("=", 1) for p in sig_header.split(",") if "=" in p
      )
      t, v1 = parts.get("t"), parts.get("v1")
      if not t or not v1:
          return False

      try:
          timestamp = int(t)
      except ValueError:
          return False
      if int(time.time()) - timestamp > max_age_seconds:
          return False

      preimage = t.encode() + b"." + raw_body
      expected = hmac.new(
          webhook_secret.encode(), preimage, hashlib.sha256
      ).hexdigest()

      return hmac.compare_digest(v1, expected)


  # Flask — request.get_data() returns the RAW body (do not read request.json first).
  @app.post("/webhooks/phosra")
  def phosra_webhook():
      raw = request.get_data()
      sig = request.headers.get("X-Phosra-Signature")
      if not verify_phosra_webhook(raw, sig, os.environ["PHOSRA_WEBHOOK_SECRET"]):
          return {"error": "invalid signature"}, 401
      event = json.loads(raw)
      # … handle event, then ack fast …
      return "", 200
  ```

  ```go Go theme={null}
  package webhook

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"fmt"
  	"io"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  // Verify reports whether sigHeader is a valid Phosra signature for rawBody.
  func Verify(rawBody []byte, sigHeader, secret string, maxAge time.Duration) bool {
  	var t, v1 string
  	for _, p := range strings.Split(sigHeader, ",") {
  		kv := strings.SplitN(p, "=", 2)
  		if len(kv) != 2 {
  			continue
  		}
  		switch kv[0] {
  		case "t":
  			t = kv[1]
  		case "v1":
  			v1 = kv[1]
  		}
  	}
  	if t == "" || v1 == "" {
  		return false
  	}

  	ts, err := strconv.ParseInt(t, 10, 64)
  	if err != nil {
  		return false
  	}
  	if time.Since(time.Unix(ts, 0)) > maxAge {
  		return false // reject replays
  	}

  	mac := hmac.New(sha256.New, []byte(secret))
  	fmt.Fprintf(mac, "%s.", t) // preimage: "<t>." + body
  	mac.Write(rawBody)
  	expected := hex.EncodeToString(mac.Sum(nil))

  	return hmac.Equal([]byte(v1), []byte(expected))
  }

  // net/http handler — read the RAW body before decoding.
  func Handler(secret string) http.HandlerFunc {
  	return func(w http.ResponseWriter, r *http.Request) {
  		raw, _ := io.ReadAll(r.Body)
  		if !Verify(raw, r.Header.Get("X-Phosra-Signature"), secret, 5*time.Minute) {
  			http.Error(w, "invalid signature", http.StatusUnauthorized)
  			return
  		}
  		// … json.Unmarshal(raw, &event), handle, then ack fast …
  		w.WriteHeader(http.StatusOK)
  	}
  }
  ```
</CodeGroup>

<Note>
  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.
</Note>

## 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:

|     Attempt | Delay before it    |
| ----------: | ------------------ |
| 1 (initial) | —                  |
|           2 | 30 seconds         |
|           3 | 1 minute           |
|           4 | 2 minutes          |
|           5 | 4 minutes          |
|        6–10 | 8 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](#inspect-delivery-history), where a
dead-lettered delivery has `success: false` and a null `next_retry_at`.

<Warning>
  **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.
</Warning>

### 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_id`s you have processed and skip
any you have seen.

<Note>
  The idempotency key is **`event_id`** (payload field) / **`X-Phosra-Event-ID`** (header). There is
  no separate `idempotency_key` field.
</Note>

```ts theme={null}
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_id`s 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:

```bash theme={null}
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](#inspect-delivery-history) returns. **Live sandbox response** (verbatim — the
`https://example.com` test URL `405`s a `POST`, so `success` is `false` and a retry is scheduled):

```json theme={null}
{
  "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](#sandbox-on-ramp-no-key-required)):

```bash theme={null}
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):

```json theme={null}
[
  {
    "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:

<ResponseField name="id" type="string">
  UUID of this delivery-attempt record. Use it to correlate log lines; it is **not** the event
  identity (that is `payload.event_id`).
</ResponseField>

<ResponseField name="webhook_id" type="string">
  UUID of the webhook this delivery belongs to (matches the `{id}` in the path).
</ResponseField>

<ResponseField name="event" type="string">
  The event name — a [catalog](#event-catalog) name (e.g. `policy.updated`) or `test`. Mirrors
  `payload.event`.
</ResponseField>

<ResponseField name="payload" type="object">
  The full signed [envelope](#delivery-format) 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](#replay-a-delivery).
</ResponseField>

<ResponseField name="response_code" type="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).
</ResponseField>

<ResponseField name="success" type="boolean">
  `true` once your endpoint returned a `2xx`. `false` while a delivery is failing, mid-retry, or
  [dead-lettered](#retry-schedule).
</ResponseField>

<ResponseField name="attempts" type="integer">
  How many delivery attempts have been made so far — `1` on the first try, up to
  `WebhookMaxAttempts = 10`.
</ResponseField>

<ResponseField name="next_retry_at" type="string">
  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`.
</ResponseField>

<ResponseField name="created_at" type="string">
  RFC 3339 timestamp of when the delivery record was created — i.e. when the event fired.
</ResponseField>

#### Pagination and filtering

<ParamField query="limit" type="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**.
</ParamField>

* **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](#replay-a-delivery) does).

### List and delete

```bash theme={null}
# 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.

<ParamField body="url" type="string" required>
  Your HTTPS endpoint. **Omitting it sets `url` to the empty string `""`** (verified live) — which
  breaks delivery. Always include it.
</ParamField>

<ParamField body="events" type="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.
</ParamField>

<ParamField body="active" type="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.
</ParamField>

```bash theme={null}
# 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}'
```

<Warning>
  **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.
</Warning>

#### Re-enable an auto-disabled webhook

When Phosra [auto-disables](#delivery-retries-and-idempotency) 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`):

```bash theme={null}
# 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):

```json theme={null}
{
  "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).

| 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](#update-a-webhook-put). |

<Note>
  **No `401` on the open sandbox.** Because the sandbox serves unauthenticated calls as the shared
  [Playground identity](#sandbox-on-ramp-no-key-required), 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.
</Note>

<Note>
  **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.
</Note>

## 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.

<Note>
  **No `phosra listen`.** Unlike Stripe's `stripe listen --forward-to`, the Phosra
  [CLI](/integration/cli) does **not** bundle a tunnel or a delivery-forwarding command. Use any
  tunnel you already trust (`cloudflared`, `ngrok`, `tailscale funnel`); the
  [test endpoint](#send-a-test-delivery) is what actually drives a delivery through it.
</Note>

<Steps>
  <Step title="Run your handler locally">
    Start the receiver on a local port. This one prints the three Phosra headers, verifies the
    signature with the [`verifyPhosraWebhook`](#verifying-signatures) helper from above, and acks
    `200`:

    <CodeGroup>
      ```ts server.ts (Node) theme={null}
      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"))
      ```

      ```python server.py (Flask) theme={null}
      import os
      from flask import Flask, request
      # verify_phosra_webhook is the helper from the "Verifying signatures" section above.

      app = Flask(__name__)

      @app.post("/webhooks/phosra")
      def hook():
          raw = request.get_data()
          sig = request.headers.get("X-Phosra-Signature")
          print("event   :", request.headers.get("X-Phosra-Event"))
          print("event-id:", request.headers.get("X-Phosra-Event-ID"))
          print("signature:", sig)
          if not verify_phosra_webhook(raw, sig, os.environ["PHOSRA_WEBHOOK_SECRET"]):
              print("  ✗ signature INVALID")
              return {"error": "invalid signature"}, 401
          print("  ✓ signature OK", raw.decode())
          return "", 200

      app.run(port=4242)
      ```
    </CodeGroup>
  </Step>

  <Step title="Expose it with a tunnel">
    Point a public HTTPS tunnel at that port and copy the URL it prints:

    ```bash theme={null}
    # 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
    ```
  </Step>

  <Step title="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](#register-a-webhook)):

    ```bash theme={null}
    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
    ```
  </Step>

  <Step title="Fire a delivery">
    Trigger the [`test` event](#test) — it travels the exact same signed path as a real event, so
    it exercises your tunnel, your signature check, and your ack:

    ```bash theme={null}
    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`](#pack-adopted)).
  </Step>

  <Step title="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:

    ```bash theme={null}
    curl -s "$PHOSRA_API/api/v1/webhooks/$WEBHOOK_ID/deliveries?limit=5" \
      -H "Authorization: Bearer $PHOSRA_API_KEY"
    ```
  </Step>
</Steps>

<Warning>
  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](#update-a-webhook-put)) rather
  than creating a second webhook, so you keep one `signing_secret` and one delivery history.
</Warning>

## Replay a delivery

When a delivery fails — your tunnel was down, your handler `500`ed, the event was
[dead-lettered](#retry-schedule) after 10 attempts — you will want to run that exact payload
through your handler again.

<Warning>
  **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](#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](#inspect-delivery-history) 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.
</Warning>

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`:

```bash theme={null}
# 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]'
```

```json theme={null}
{
  "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"
}
```

<Note>
  `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.
</Note>

### 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](#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](#idempotency) logic sees the **same `event_id`** — exactly what you want to test.

<CodeGroup>
  ```bash curl / openssl theme={null}
  # 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
  ```

  ```ts TypeScript (Node) theme={null}
  import { createHmac } from "node:crypto"

  const API = process.env.PHOSRA_API!
  const KEY = process.env.PHOSRA_API_KEY!
  const SECRET = process.env.PHOSRA_WEBHOOK_SECRET!
  const WEBHOOK_ID = process.env.WEBHOOK_ID!
  const TARGET = "http://localhost:4242/webhooks/phosra"

  // 1. Read the delivery history and pick the first failed delivery.
  const deliveries = await fetch(
    `${API}/api/v1/webhooks/${WEBHOOK_ID}/deliveries?limit=50`,
    { headers: { Authorization: `Bearer ${KEY}` } },
  ).then((r) => r.json())

  const failed = deliveries.find((d: any) => d.success === false)
  if (!failed) throw new Error("no failed delivery to replay")

  // 2. Re-serialize the stored envelope to the exact bytes we will sign.
  const raw = JSON.stringify(failed.payload)
  const t = Math.floor(Date.now() / 1000).toString()
  const v1 = createHmac("sha256", SECRET).update(`${t}.${raw}`).digest("hex")

  // 3. POST it back at your handler with a freshly minted signature.
  const res = await fetch(TARGET, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Phosra-Signature": `t=${t},v1=${v1}`,
      "X-Phosra-Event": failed.event,
      "X-Phosra-Event-ID": failed.payload.event_id,
    },
    body: raw,
  })
  console.log("replayed →", res.status)
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import json
  import os
  import time

  import requests

  API = os.environ["PHOSRA_API"]
  KEY = os.environ["PHOSRA_API_KEY"]
  SECRET = os.environ["PHOSRA_WEBHOOK_SECRET"].encode()
  WEBHOOK_ID = os.environ["WEBHOOK_ID"]
  TARGET = "http://localhost:4242/webhooks/phosra"

  # 1. Read the delivery history and pick the first failed delivery.
  deliveries = requests.get(
      f"{API}/api/v1/webhooks/{WEBHOOK_ID}/deliveries",
      params={"limit": 50},
      headers={"Authorization": f"Bearer {KEY}"},
  ).json()
  failed = next((d for d in deliveries if d["success"] is False), None)
  if failed is None:
      raise SystemExit("no failed delivery to replay")

  # 2. Re-serialize the stored envelope to the exact bytes we will sign.
  raw = json.dumps(failed["payload"], separators=(",", ":")).encode()
  t = str(int(time.time()))
  v1 = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()

  # 3. POST it back at your handler with a freshly minted signature.
  res = requests.post(
      TARGET,
      data=raw,
      headers={
          "Content-Type": "application/json",
          "X-Phosra-Signature": f"t={t},v1={v1}",
          "X-Phosra-Event": failed["event"],
          "X-Phosra-Event-ID": failed["payload"]["event_id"],
      },
  )
  print("replayed →", res.status_code)
  ```
</CodeGroup>

<Note>
  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](#verifying-signatures)). The
  `event_id` inside the payload is unchanged, so your handler's de-duplication still recognises it as
  the same event.
</Note>

### 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](#delivery-retries-and-idempotency)
following 3 dead-letters — do this:

1. **Re-enable the webhook** if it was auto-disabled, by `PUT`ting `active: true`
   (see [Update a webhook](#update-a-webhook-put)).
2. **Fire a fresh delivery** with [`POST /test`](#send-a-test-delivery). Note this sends a new
   [`test`](#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

<AccordionGroup>
  <Accordion title="Verify the signature before anything else" icon="shield-check">
    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.
  </Accordion>

  <Accordion title="De-duplicate on event_id" icon="copy">
    Retries mean at-least-once delivery. Skip any `event_id` (`X-Phosra-Event-ID`) you have already
    processed, backed by a durable store.
  </Accordion>

  <Accordion title="Ack fast, process async" icon="bolt">
    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.
  </Accordion>

  <Accordion title="Monitor delivery health" icon="heart-pulse">
    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.
  </Accordion>

  <Accordion title="HTTPS only" icon="lock">
    Webhook URLs must use HTTPS in production.
  </Accordion>
</AccordionGroup>
