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
API-Key: sk_live_...
Access-Key-Id: ak_live_...
Content-Type: application/json
{
"url": "https://yourcompany.com/webhooks/attestid",
"secret": "a-random-secret-you-generate-yourself"
}

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

secret is one you generate, at least 16 characters, and can't be one of your API keys — we reject it if it looks like one.

Also available from the dashboard. Once set, the secret is never returned, only whether one exists:

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

You can pass webhook_url at registration, but that alone doesn't set a secret. Follow up with /v1/webhooks/configure before relying on deliveries being signed.

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 — e.g. "duplicate_identity", "document_tampered".

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.

How delivery works

POST with Content-Type: application/json and an AttestID-Signature header. Any 2xx counts as success; anything else, including a timeout, gets retried — three times, at 60s, 120s, and 240s, then we give up.

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

Verifying the signature

t=1735689600,v1=5257a869e7bfa8e...

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

import crypto from 'crypto';

function verifyAttestIDWebhook(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const { t: timestamp, v1: receivedSignature } = parts;
if (!timestamp || !receivedSignature) return false;

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

const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');

return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(receivedSignature));
}

// 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');

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

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

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.com