> Markdown version of https://agledger.ai/docs/guides/webhooks/
> Full index of this site for AI assistants: https://agledger.ai/llms.txt

# Webhooks

AGLedger delivers every business-meaningful record event to an endpoint you register, as an HTTPS POST. This guide covers the four things you do with that stream: register an endpoint, receive an event, verify its signature, and handle retries and failures. For why the two signing schemes exist and which to pick, see the [Notify capability page](/notify/); this page is the how.

## 1. Register an endpoint

Create a subscription with `POST /v1/webhooks`. Give it a URL and the event types you want - or `["*"]` for everything. The URL must be HTTPS, and private, link-local, and cloud-metadata addresses are rejected at connect time.

```bash
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/agledger",
    "eventTypes": ["record.created", "record.fulfilled", "signal.emitted", "signal.received"]
  }' \
  "$AGLEDGER_API_URL/v1/webhooks"
```

```json
{
  "id": "019e6210-5f3a-7b21-9c8e-2b1f4a6d7e90",
  "url": "https://hooks.example.com/agledger",
  "eventTypes": ["record.created", "record.fulfilled", "signal.emitted", "signal.received"],
  "recordTypes": ["*"],
  "format": "standard",
  "signingAlg": "ed25519",
  "secret": null,
  "isActive": true,
  "isPaused": false,
  "circuitState": "closed"
}
```

Note `signingAlg`. This subscription lists settlement events (`signal.emitted`, `signal.received`), so on a Server that has a vault signing key it defaults to `ed25519` - and `secret` is `null` because no shared secret is involved. A subscription with only ordinary lifecycle events defaults to `hmac` and returns a `secret` **once, in this response only**:

```json
{
  "id": "019e6210-7c44-7c10-b3a2-9d0e1f2a3b4c",
  "signingAlg": "hmac",
  "secret": "whsec_9f8e7d6c5b4a39281706f5e4d3c2b1a0..."
}
```

Capture the secret now; it cannot be retrieved later. Rotate it with `POST /v1/webhooks/{id}/rotate` (the previous secret stays valid for a short grace window). To force a scheme explicitly, pass `signingAlg` on create: `hmac`, or the RFC 9421 registered name matching the active vault key's algorithm. That is `ed25519` on a default Server and `ecdsa-p256-sha256` on one opted into ES256 (see [FIPS 140 hosts](/docs/install/fips/)). A signed scheme returns `422` if the Server has no signing key, and naming the algorithm the Server does not hold returns `422` listing what it can use - a subscription never silently downgrades to a scheme you did not ask for.

### Scope a subscription by record type

`eventTypes` picks *which lifecycle moments* you hear about; `recordTypes` picks *which records*.
When one Server feeds audiences that must not see each other's records - a regulator channel and a
customer channel, say - give each subscription a `recordTypes` list of contract type names:

```bash
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.regulator.example.com/agledger",
    "eventTypes": ["record.created", "record.fulfilled"],
    "recordTypes": ["sar-report-v1"]
  }' \
  "$AGLEDGER_API_URL/v1/webhooks"
```

The filter is applied server-side and **fails closed**: a subscription that declares `recordTypes`
never receives a record-scoped event whose record's type it does not list, so the regulator channel
above can never leak a customer-notice record even if the endpoint or the event list is
misconfigured. Events with no record in play (owner-level key lifecycle events) are unaffected.

Omit the field to receive every type - responses echo `"recordTypes": ["*"]` in that case - and on
`PATCH /v1/webhooks/{id}`, send `["*"]` to clear an existing filter. Entries are names in your own
Type namespace and are deliberately not checked against the schema registry, so a subscription can
pre-date the Type it routes. Available from API v1.2.0; the release's automatic migration adds the
underlying column, and existing subscriptions keep their receive-everything behavior.

Send yourself a test event before wiring anything up:

```bash
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_API_KEY" \
  "$AGLEDGER_API_URL/v1/webhooks/019e6210-5f3a-7b21-9c8e-2b1f4a6d7e90/ping"
```

## 2. Receive an event

Each delivery is a POST with a JSON body. The default envelope:

```json
{
  "id": "019e6211-aa01-7def-8123-4567890abcde",
  "type": "signal.emitted",
  "recordId": "019e6209-1234-7000-9abc-def012345678",
  "data": {
    "recommendation": "SETTLE",
    "outcome": "accept",
    "performerAgentId": "019e61d4-fbb9-780f-b110-8a64ab46920f",
    "platformRef": "invoice-4815"
  },
  "eventSequence": 42,
  "createdAt": "2026-05-25T18:30:00.000Z"
}
```

The `status` field on lifecycle events uses display names (`CREATED`, `FULFILLED`, `FAILED`, `RECORDED`, `EXPIRED`), not internal state-machine names. Set `"format": "cloudevents"` on the subscription to receive a CloudEvents 1.0 envelope instead, with the same object nested under `data`.

Two headers ride on every delivery:

| Header | Meaning |
|---|---|
| `X-AGLedger-Idempotency-Key` | The event id. Minted once, replayed verbatim on every retry - **dedup on this.** |
| `X-AGLedger-Delivery` | A per-attempt id for log correlation. Changes on retry - do not dedup on it. |

Read the raw request body **before** any JSON parsing or re-serialization. Both signature schemes are computed over the exact bytes on the wire; a framework that reformats the body will break verification.

## 3. Verify the signature

Check the subscription's `signingAlg` once and run the matching verifier on every delivery.

### HMAC (`signingAlg: "hmac"`)

The signature is in `X-AGLedger-Signature: t=<unix>,v1=<hex>`, computed as `HMAC-SHA256(secret, "<t>.<rawBody>")`. The literal `t=` is a parser prefix, not part of the signed input. Reject deliveries whose timestamp is more than 300 seconds old to prevent replay.

```js
import { createHmac, timingSafeEqual } from 'node:crypto'

function verifyHmac(rawBody, signatureHeader, secret) {
  const m = /t=(\d+),v1=([0-9a-f]+)/.exec(signatureHeader)
  if (!m) return false
  const [, t, hex] = m
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false // replay window

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  const a = Buffer.from(hex, 'hex')
  const b = Buffer.from(expected, 'hex')
  return a.length === b.length && timingSafeEqual(a, b)
}
```

### Asymmetric / RFC 9421 (`signingAlg: "ed25519"` or `"ecdsa-p256-sha256"`)

The delivery carries three RFC 9421 headers:

```
Content-Digest:  sha-256=:<base64(sha256(rawBody))>:
Signature-Input: sig1=("content-digest" "x-agledger-idempotency-key");created=<unix>;keyid="<kid>";alg="ed25519"
Signature:       sig1=:<base64(signature)>:
```

You verify against the Server's public key - no secret. Fetch the keys once and cache them; resolve the key whose `keyId` matches the `keyid` in `Signature-Input`:

```bash
curl -s "$AGLEDGER_API_URL/v1/verification-keys"
```

```json
{
  "data": [
    {
      "keyId": "9a3f...c1",
      "algorithm": "ed25519",
      "publicKey": "<base64 SPKI-DER>",
      "publicKeyRaw": "<base64 32-byte raw>",
      "status": "active"
    }
  ]
}
```

Rebuild the signature base exactly as the Server did - one line per covered component in order, then the `@signature-params` line carrying the bytes after `sig1=` in `Signature-Input`, joined with `\n` - and verify:

```js
import { createHash, createPublicKey, verify } from 'node:crypto'

function verifyEd25519(headers, rawBody, keysById) {
  const sigInput = headers['signature-input']
  const sigHeader = headers['signature']
  const contentDigest = headers['content-digest']
  const idempotencyKey = headers['x-agledger-idempotency-key']
  if (!sigInput || !sigHeader || !contentDigest || !idempotencyKey) return false

  // 1. The digest must match the body we received.
  const digest = `sha-256=:${createHash('sha256').update(rawBody, 'utf8').digest('base64')}:`
  if (contentDigest !== digest) return false

  // 2. Split "sig1=<params>" and "sig1=:<base64>:".
  const params = sigInput.replace(/^sig1=/, '')
  const sigB64 = /^sig1=:(.*):$/.exec(sigHeader)?.[1]
  if (!sigB64) return false

  // 3. Resolve the key by keyid, and enforce the replay window via `created`.
  const keyId = /keyid="([^"]+)"/.exec(params)?.[1]
  const created = Number(/created=(\d+)/.exec(params)?.[1])
  const key = keysById.get(keyId)
  if (!key || Math.abs(Date.now() / 1000 - created) > 300) return false

  // 4. Reconstruct the signature base (LF-joined, no trailing newline).
  const base = [
    `"content-digest": ${contentDigest}`,
    `"x-agledger-idempotency-key": ${idempotencyKey}`,
    `"@signature-params": ${params}`,
  ].join('\n')

  const publicKey = createPublicKey({
    key: Buffer.from(key.publicKey, 'base64'),
    format: 'der',
    type: 'spki',
  })
  return verify(null, Buffer.from(base, 'utf8'), publicKey, Buffer.from(sigB64, 'base64'))
}
```

Because this is the Server's own vault key - the same one that signs the chain - a verified `signal.emitted` is provable to a third party. That is what makes a Settlement Signal trustworthy when it crosses into a counterparty's payment system.

**On a Server opted into ES256**, the scheme is `ecdsa-p256-sha256` and the wire format is identical: same three headers, same signature base, with `alg="ecdsa-p256-sha256"` in `Signature-Input` and the key's `algorithm` reading `ES256` in the registry. Only the final `verify` call changes, since Node resolves the curve from the SPKI key:

```js
// ES256: hash-then-sign, and RFC 9421 carries the raw r||s pair, not DER.
return verify(
  'sha256',
  Buffer.from(base, 'utf8'),
  { key: publicKey, dsaEncoding: 'ieee-p1363' },
  Buffer.from(sigB64, 'base64'),
)
```

Both differences matter. ECDSA signs a digest where Ed25519 signs the message, so the algorithm argument goes from `null` to `'sha256'`; and Node emits DER by default while RFC 9421 specifies the raw 64-byte pair, so omitting `dsaEncoding` fails every valid signature. Read `algorithm` from the key you resolved by `keyid` rather than assuming one, and a receiver written that way keeps working across a rotation between algorithms.

## 4. Idempotency and ordering

Delivery is **at-least-once**. The same event can arrive more than once, so treat a repeated `X-AGLedger-Idempotency-Key` as a no-op on your side.

Ordering is **best-effort, not guaranteed**. Delivery is serialized per subscription, but a retried event whose first attempt failed can land after a later event that succeeded immediately. If you drive a state machine off the stream, branch on the `status` field in the payload rather than on arrival order.

## 5. Retries, circuit breaker, and the dead-letter queue

A failed delivery retries with exponential backoff - roughly six attempts over about five minutes - then moves to the subscription's dead-letter queue. After ten consecutive failures the circuit breaker opens and new events go straight to the DLQ without an attempt; it closes again on the first success.

When a receiver has been down, work the recovery surface:

```bash
# See what failed — same body shape as a live delivery
curl -s -H "Authorization: Bearer $AGLEDGER_API_KEY" \
  "$AGLEDGER_API_URL/v1/webhooks/{id}/dlq"

# Replay one entry, or drain the whole queue
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_API_KEY" \
  "$AGLEDGER_API_URL/v1/webhooks/{id}/dlq/{dlqId}/retry"

curl -s -X POST -H "Authorization: Bearer $AGLEDGER_API_KEY" \
  "$AGLEDGER_API_URL/v1/webhooks/{id}/dlq/retry-all"
```

DLQ entries are retained for seven days. If the breaker is open after you have fixed the receiver, an admin closes it with `PATCH /v1/admin/webhooks/{id}/circuit-breaker` (body `{"state":"closed"}`) before draining.

To stop deliveries without losing the subscription, use `POST /v1/webhooks/{id}/pause` and `/resume`. Events that arrive while paused are dropped, not queued.

---

*Updated for API v1.4.0 on 2026-08-09: signing-algorithm agility adds a third `signingAlg` value,
`ecdsa-p256-sha256`, for a Server opted into ES256; the RFC 9421 wire format is unchanged and the
verifier differs only in its `verify` call. Delivery, retry, DLQ, and idempotency behavior are
unchanged. Not re-run end to end against a live v1.4.0 install.*

*Validated against API v1.2.0 on 2026-07-06 (Developer Edition, Docker Compose: webhook routes, signing wire formats, verification recipes). The Ed25519 verifier mirrors the Server's reference implementation; confirm field-level details against the [API reference](/api/) and `GET /v1/verification-keys` on your own Server.*
