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"],
"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:
{
"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.
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_KEY" -H "Content-Type: application/json" \
-d '{
"url": "https://hooks.regulator.example.com/agledger",
"eventTypes": ["record.created", "record.fulfilled"],
"recordTypes": ["sar-report-v1"]
}' \
"$AGLEDGER_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:
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": "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.
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.
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 and GET /v1/verification-keys on your own Server.