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
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 — 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