Webhooks

Webhook events

Real-time notification of agent platform events. POSTs are signed with HMAC-SHA256, retried with exponential backoff, and deactivated after persistent failure so a dead consumer doesn't keep eating queue capacity.

Event types

FieldTypeDescription
attestation.created
event
A propose_trade call succeeded and a ServiceRequest is pending the user’s attestation.
attestation.accepted
event
The user accepted a pending attestation request in the AC app.
attestation.rejected
event
The user rejected a pending attestation request.
attestation.expired
event
A pending attestation request hit its expiration window unaccepted.
validation.blocked
event
A validate_trade call returned allowed: false. Useful for async logging of failed proposals.
key.revoked
event
An agent key was revoked (manually or by an admin).
key.expired
event
An agent key passed its expiresAt timestamp.

Payload shape

Common envelope
{
  "id": "1d4f7c8e-9b2a-4d6f-…",      // unique per delivery
  "type": "attestation.created",
  "createdAt": "2026-06-15T14:22:11.034Z",
  "data": {
    "serviceRequestId": 4193,
    "agentKeyId": "8a0b…",
    "action": "buy",
    "assetSymbol": "BTC",
    "assetType": "crypto",
    "amountUsd": 5000,
    "rationale": "Add a 1% nudge while crypto sleeve has headroom.",
    "ipsVersion": 7
  }
}

Signature verification

We sign every delivery with the secret shown once at subscription time. The header is X-AC-Signature in the form t=<unix-seconds>,v1=<hex>. The HMAC body is <unix-seconds>.<raw-request-body> — the timestamp is inside the HMAC to defeat replay.

Node verifier
import { createHmac, timingSafeEqual } from 'crypto';

export function verify(rawBody: string, header: string, secret: string) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=') as [string, string]),
  );
  const ts = Number(parts.t);
  const sig = parts.v1;
  if (!ts || !sig) return false;
  // Reject deliveries older than 5 minutes.
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > 300) return false;

  const expected = createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`)
    .digest('hex');
  if (expected.length !== sig.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}

Request headers we send

FieldTypeDescription
Content-Type
header
Always application/json.
User-Agent
header
advisors-crypto-webhooks/<version>
X-AC-Signature
header
t=<unix-seconds>,v1=<hex HMAC-SHA256>
X-AC-Event
header
The event type, e.g. 'attestation.created'. Matches data.type.
X-AC-Delivery-Id
header
A unique id per delivery attempt — use it for idempotent processing.

Retries

  • A delivery is considered successful only if your endpoint returns a 2xx within 10 seconds.
  • On failure we retry up to 6 times with exponential backoff starting at 5 seconds.
  • After 20 consecutive failures on the same webhook we flip isActive to false. You re-enable it manually after fixing the consumer.

Idempotency on your side

We may deliver the same event more than once — a transient 5xx from your endpoint that we retry, a redelivery if your handler responded too slowly. Treat X-AC-Delivery-Id as the dedupe key and store a short-lived “already processed” cache on your side.

Responding

Acknowledge as fast as you can — ideally under one second. Do real work after responding. We don’t care about the response body; status code is all that matters.

ts
app.post('/webhooks/ac', express.raw({ type: '*/*' }), (req, res) => {
  if (!verify(req.body.toString(), req.header('X-AC-Signature')!, SECRET)) {
    return res.status(401).end();
  }
  res.status(204).end();              // ack fast
  enqueueProcessing(req.body);        // do work async
});
Last updated 2026-06-15