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:
- Call
POST /v1/webhooks/configure. You get the new secret. For the next 24 hours (untilprevious_secret_expires_at), every delivery is signed with both the new and the previous secret. - Deploy the new secret to your receiver. Once it verifies with the new secret, call
POST /v1/webhooks/rotation/confirmto 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
event | Fired when |
|---|---|
verification.completed | A verification finishes, pass or fail |
fraud.detected | A 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:
| Header | What it is |
|---|---|
AttestID-Signature | HMAC-SHA256 with your webhook secret. Proves the delivery came from someone holding the secret |
AttestID-JWS | A 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
With the SDK (recommended)
@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);
}
});
secretschecksAttestID-Signature.apiUrlchecksAttestID-JWSagainst the public keys at/.well-known/jwks.json(cached).- Both timestamps must be within
toleranceSeconds(default 300) to block replays. - It throws
SignatureErrorif 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