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.