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; 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.
Every webhook route takes an org-admin or platform key: webhooks:manage to create or change a subscription, webhooks:read to read one. No agent scope profile carries either, so an agent key is refused here even for work it owns.
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_ADMIN_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"
{
"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; federation.settlement.signal counts too), so on a Server that has a vault signing key it defaults to ed25519, and secret is null because no shared secret is involved. The lifecycle outcome events record.fulfilled and record.failed do not trigger that default: a payment or ERP subscription that needs vault-signed deliveries lists signal.emitted beside them or sets signingAlg. A subscription with only lifecycle events defaults to hmac and returns a secret once, in this response only:
{
"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 secret it replaces keeps signing beside the new one for WEBHOOK_SECRET_GRACE_SECONDS (300 by default), and the response carries secretGraceActive and secretGraceExpiresAt. 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). 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:
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_ADMIN_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.
Send yourself a signed webhook.test event before wiring anything up:
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_ADMIN_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:
{
"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. While a rotation's grace window is open the header carries a second v1= entry, one per secret still signing, so check every entry and accept the delivery when any of them verifies. Reject deliveries whose timestamp is more than 300 seconds old to prevent replay.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verifyHmac(rawBody, signatureHeader, secret) {
const t = /(?:^|,)t=(\d+)/.exec(signatureHeader)?.[1]
if (!t) return false
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false // replay window
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
// One v1= entry per secret currently signing; accept if any matches.
return [...signatureHeader.matchAll(/v1=([0-9a-f]+)/g)].some(([, hex]) => {
const got = Buffer.from(hex, 'hex')
return got.length === expected.length && timingSafeEqual(got, expected)
})
}
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 and cache them, and resolve the key whose keyId matches the keyid in Signature-Input; on a keyid your cache does not hold, or when told a key was retired, fetch again before accepting anything signed under it (the document is served Cache-Control: no-cache for that reason):
curl -s "$AGLEDGER_API_URL/v1/verification-keys"
{
"data": [
{
"keyId": "9a3f...c1",
"algorithm": "Ed25519",
"publicKey": "<base64 SPKI-DER>",
"publicKeyRaw": "<base64 32-byte raw>",
"status": "active",
"activatedAt": "2026-05-01T00:00:00.000Z",
"retiredAt": null,
"statements": [
{ "id": "019e5a01-...", "kind": "genesis", "createdAt": "2026-05-01T00:00:00.000123Z", "cose": ["<base64 COSE_Sign1>"] }
]
}
],
"anchoredFrom": "sha256:4be1...07"
}
The response is abbreviated. statements are the signed key statements that admit the key, and anchoredFrom is the sha256: digest of the key the serving process signs with; compare it once against the pin your operator took at install, since a key list is only as trustworthy as whatever served it.
Match keyid exactly and do not require status: "active". Several keys can be active at once during a rotation, and a key retired after it signed a delivery publishes status: "retired" with its retiredAt. Accept the delivery when created falls inside that key's [activatedAt, retiredAt] window (retiredAt null means still open), allowing a few seconds of clock skew at each edge.
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:
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™ webhook 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:
// 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 2xx is delivered. A 5xx, 408, 429, 405 or network failure retries with jittered exponential backoff: one attempt plus six retries, spanning roughly 5 to 10 minutes, then the event moves to the subscription's dead-letter queue. A 429 or 503 carrying Retry-After moves that one retry to the time you named, without adding to the budget. 410 Gone deactivates the subscription. Every other 4xx, and a permanent redirect (301/308), goes to the DLQ on the first attempt, because redirects are never followed; point the subscription at the final URL with PATCH /v1/webhooks/{id} and replay.
After ten consecutive failed deliveries the circuit breaker opens (a 429 does not count toward it; a 503 does). While it is open the subscription is skipped at dispatch: new events are dropped, not dead-lettered, and the way to recover them is to replay GET /v1/events?since=<the instant the breaker opened>. The breaker closes on the first successful delivery after a cool-down.
When a receiver has been down, work the recovery surface:
# See what failed: same type and data as a live delivery, plus errorMessage and attempts
curl -s -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
"$AGLEDGER_API_URL/v1/webhooks/{id}/dlq"
# Replay one entry, or drain the whole queue
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
"$AGLEDGER_API_URL/v1/webhooks/{id}/dlq/{dlqId}/retry"
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_ADMIN_KEY" \
"$AGLEDGER_API_URL/v1/webhooks/{id}/dlq/retry-all"
DLQ entries are kept until you replay or discard them; nothing ages them out. DELETE /v1/webhooks/{id}/dlq/{dlqId} discards one without delivering it (the event is still on GET /v1/events and the record). retry-all takes up to 100 entries per call. Deleting a subscription deactivates it and leaves its dead letters in place: the DELETE response counts them in deadLetters, a retry of one is refused from then on, and each keeps system health degraded until you discard it. 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"}, admin:system scope) 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.
6. Operate subscriptions
Everything about a subscription is an API call, so nothing needs a redeploy:
GET /v1/webhookslists subscriptions;PATCH /v1/webhooks/{id}changes the URL, event types, or record types in place.GET /v1/webhooks/{id}/deliveriesis the per-attempt delivery log: what each attempt sent, what the receiver returned, and when.POST /v1/webhooks/{id}/rotaterotates an HMAC secret. Both secrets sign during the grace window, so consumers update on their own schedule.GET /v1/admin/webhooks/health(admin key) returns one row per subscription with breaker state and last-success timestamp, shaped for a dashboard.- On a config-as-code install, subscriptions are declared in provisioning YAML with operator-supplied secrets and reconciled on boot. Such a subscription reads
managedBy: "provisioning"and answers409 PROVISIONING_MANAGEDto DELETE, PATCH and rotate; change it in the declaration and reload. Pause and resume still work on it. See Provisioning.