Skip to main content

Webhooks

Instead of polling GET /v1/kyc/{verification_id}, give AttestID a URL and we'll call it. When a verification finishes, or a fraud signal is confirmed, we send a signed POST with the outcome.

Setting up a webhook​

POST /v1/webhooks/configure
X-API-Key: sk_live_...
X-Access-Key-Id: ak_live_...
Content-Type: application/json
(+ your client certificate on the TLS connection)
{
"url": "https://yourcompany.com/webhooks/attestid"
}

Response:

{
"url": "https://yourcompany.com/webhooks/attestid",
"secret_set": true,
"secret": "whsec_3f9a...",
"previous_secret_expires_at": null
}

AttestID generates the signing secret (whsec_...) and returns it once, in this response. Store it in your secrets manager straight away: it can't be retrieved again. You no longer supply your own secret.

url must be HTTPS in production. Private, loopback and reserved addresses are always rejected, even if that's what your hostname resolves to.

/v1/webhooks/* requires an API key, with your client certificate. SDK tokens can't change webhook settings.

You can also set up webhooks from the dashboard (Webhook). Once set, the secret is never returned, only whether one exists:

GET /v1/webhooks/configure # { url, secret_set, previous_secret_expires_at }
DELETE /v1/webhooks/configure # stop delivery; removes url and secret

You can pass webhook_url at registration, but that doesn't create a secret. Call /v1/webhooks/configure (or use the dashboard) before relying on signed deliveries.

Rotating the secret​

Every call to POST /v1/webhooks/configure mints a new secret, including a call that only changes the URL. Rotation has no downtime:

  1. Call POST /v1/webhooks/configure. You get the new secret. For the next 24 hours (until previous_secret_expires_at), every delivery is signed with both the new and the previous secret.
  2. Deploy the new secret to your receiver. Once it verifies with the new secret, call POST /v1/webhooks/rotation/confirm to stop signing with the old one straight away.

During the overlap, your receiver can accept either secret. The verifyWebhook() helper below takes a list of secrets for this.

What you'll receive​

eventFired when
verification.completedA verification finishes, pass or fail
fraud.detectedA fraud signal is confirmed
{
"event": "verification.completed",
"verification_id": "9c1e...",
"company_id": "5b1e...",
"verified": true,
"fraud_rules": []
}

For fraud.detected, verified is false and fraud_rules lists what triggered it, for example "expired_document", "tamper_detected" or "identity_reuse_suspected".

The payload is intentionally thin. Treat it as a signal to fetch the full result from GET /v1/kyc/{verification_id}, not as your source of truth. That response includes a signed attestation you can verify (see Security → Signed results).

How delivery works​

POST with Content-Type: application/json and two signature headers:

HeaderWhat it is
AttestID-SignatureHMAC-SHA256 with your webhook secret. Proves the delivery came from someone holding the secret
AttestID-JWSA detached ES256 signature with AttestID's platform key. Anyone can verify it with AttestID's public keys, so it also proves to a third party (an auditor, for example) that AttestID sent it

Any 2xx counts as success. Anything else, including a timeout, is retried three times, at 60 s, 120 s and 240 s, then we give up.

We wait 10 seconds for a response, so acknowledge quickly and process asynchronously.

Verifying the signature​

@attestid/sdk includes verifyWebhook() for your Node.js backend (Node 19+, or any runtime with WebCrypto). Pass the raw request body, before JSON parsing:

import express from 'express';
import { verifyWebhook } from '@attestid/sdk';

app.post('/webhooks/attestid', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await verifyWebhook(req.body, req.headers, {
// During a rotation, list both secrets: either one verifies.
secrets: [process.env.ATTESTID_WEBHOOK_SECRET!, process.env.ATTESTID_WEBHOOK_SECRET_OLD]
.filter(Boolean) as string[],
apiUrl: process.env.ATTESTID_API_URL, // also verifies the AttestID-JWS header
});
// handle event.event === 'verification.completed' | 'fraud.detected'
res.sendStatus(200);
} catch {
res.sendStatus(400);
}
});
  • secrets checks AttestID-Signature.
  • apiUrl checks AttestID-JWS against the public keys at /.well-known/jwks.json (cached).
  • Both timestamps must be within toleranceSeconds (default 300) to block replays.
  • It throws SignatureError if anything doesn't match.

Without the SDK​

The header looks like this. During a rotation it contains two v1 values, one per secret:

t=1735689600,v1=5257a869e7bfa8e...
t=1735689600,v1=5257a869e7bfa8e...,v1=9c03be1f7d2a4c6...

Each v1 is HMAC-SHA256(secret, "{t}.{raw_request_body}"), hex-encoded. To verify: recompute it over the raw body (not re-serialized JSON), accept the delivery if any v1 matches using a constant-time comparison, and reject it if t is more than 5 minutes old.

import crypto from 'crypto';

function verifyAttestIDWebhook(rawBody: string, header: string, secrets: string[]): boolean {
let timestamp = '';
const received: string[] = [];
for (const part of header.split(',')) {
const [key, value] = part.split('=', 2);
if (key === 't') timestamp = value;
if (key === 'v1') received.push(value); // keep every v1, not just the last
}
if (!timestamp || received.length === 0) return false;

if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5 min

return secrets.some(secret => {
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'),
);
return received.some(sig => {
const candidate = Buffer.from(sig);
return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
});
});
}

// Use a raw body parser for this route so rawBody stays untouched
app.post('/webhooks/attestid', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('AttestID-Signature') ?? '';
const rawBody = req.body.toString('utf8');
const secrets = [process.env.ATTESTID_WEBHOOK_SECRET!, process.env.ATTESTID_WEBHOOK_SECRET_OLD]
.filter(Boolean) as string[];

if (!verifyAttestIDWebhook(rawBody, signature, secrets)) {
return res.status(401).send('Invalid signature');
}

const event = JSON.parse(rawBody);
// handle event.event === 'verification.completed' | 'fraud.detected'
res.status(200).send('ok');
});

:::warning Upgrading from older sample code Earlier versions of this page parsed the header with Object.fromEntries(...), which keeps only the last v1. During a rotation that's the signature made with the old secret, so a receiver already using the new secret would reject valid deliveries. Use the loop above, or verifyWebhook(). :::

Your webhook URL is public. Anyone can POST to it, so skipping signature verification means trusting unauthenticated input.

Checking delivery history​

GET /v1/company/webhook/deliveries?limit=50&delivered=false

Each entry rolls up one delivery's retries: event type, attempts, last HTTP status, and whether it ultimately succeeded.

Support​

For help debugging webhook delivery: support@brimsage.co.ls