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 webhooks 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.
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_KEY" -H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/agledger",
"eventTypes": ["record.created", "record.fulfilled", "signal.emitted", "signal.received"]
}' \
"$AGLEDGER_URL/v1/webhooks"
{
"id": "019e6210-5f3a-7b21-9c8e-2b1f4a6d7e90",
"url": "https://hooks.example.com/agledger",
"eventTypes": ["record.created", "record.fulfilled", "signal.emitted", "signal.received"],
"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:
{
"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": "ed25519" or "hmac" on create — ed25519 returns 422 if the Server has no signing key.
Send yourself a test event before wiring anything up:
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_KEY" \
"$AGLEDGER_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": "PASS",
"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.
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)
}
Ed25519 / RFC 9421 (signingAlg: "ed25519")
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:
curl -s "$AGLEDGER_URL/v1/verification-keys"
{
"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:
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.
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:
# See what failed — same body shape as a live delivery
curl -s -H "Authorization: Bearer $AGLEDGER_KEY" \
"$AGLEDGER_URL/v1/webhooks/{id}/dlq"
# Replay one entry, or drain the whole queue
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_KEY" \
"$AGLEDGER_URL/v1/webhooks/{id}/dlq/{dlqId}/retry"
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_KEY" \
"$AGLEDGER_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.
Webhook routes, the signing wire formats, and the verification recipes on this page are drawn from the AGLedger webhook module at API v0.25.4. The Ed25519 verifier mirrors the Server's reference implementation; confirm field-level details against the API reference and GET /v1/verification-keys on your own Server.