File DA-043 View as Markdown
Debrief webhook
Double Agent can post the server's verdict for a session to your own endpoint. Use it to update your database, your CRM or a payment when the server sees something the browser did not. This page is also where to learn to verify any Double Agent webhook, including caller.scope_violation.
Before you start: a public key on the snippet, because only keyed traffic gets server verdicts (Capabilities), and the Owner or Admin role on the site.
1. Set the URL
Open Sites › your site › Settings › Debrief webhook. Enter your https:// URL and a signing secret, a long random string you keep on your server too. Save. The secret is write-only: the portal never shows it again.
2. Receive and verify
Each delivery has a header like DoubleAgent-Signature: t=1758614400,v1=5f8a…. To check it:
- Read the raw body as text, before you parse it.
- Compute
hex(HMAC-SHA256(secret, t + "." + rawBody)). - Compare it with
v1in constant time. - Reject when
tis more than 300 seconds from your clock.
With Express:
import crypto from 'node:crypto';
import express from 'express';
const secret = process.env.DOUBLEAGENT_WEBHOOK_SECRET!;
function verified(raw: string, header = ''): boolean {
const parts = Object.fromEntries(header.split(',').map((part) => part.trim().split('=', 2)));
const t = Number(parts.t);
if (!parts.v1 || !Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex');
return expected.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
const app = express();
app.post('/webhooks/doubleagent', express.text({ type: 'application/json' }), async (req, res) => {
if (!verified(req.body, req.get('DoubleAgent-Signature'))) return res.status(400).end();
const event = JSON.parse(req.body);
if (await alreadyHandled(event.id)) return res.status(200).end();
if (event.type === 'verdict.final') await updateSession(event.data.sid, event.data.verdict);
res.status(200).end();
});
With a fetch-style handler (Next.js route handlers, Hono, Workers), read the body with await request.text(), run the same check, then JSON.parse it. Workers have no node:crypto by default: use crypto.subtle with an HMAC key.
What you receive
POST /webhooks/doubleagent
Content-Type: application/json
DoubleAgent-Signature: t=1758614400,v1=5f8a…
User-Agent: DoubleAgent-Webhooks/1
{ "id": "evt_…", "type": "verdict.final", "created": 1758614400,
"data": { "sid": "s_…", "site": "st_…", "provisional": "human",
"verdict": { "class": "agent", "behavior": { "label": "neutral", … }, … },
"ids": { "ga_client_id": "…" },
"intent": { "code": "task", "risk": "none", … } } }
| Field | |
|---|---|
id | The same for the same verdict. Use it to skip a delivery you already handled |
data.provisional | The class the browser had |
data.verdict | The server's full verdict |
data.ids | Join ids the browser reported, such as ga_client_id or shopify_cart_token |
data.intent | The session's intent, when known. It never changes the verdict |
When it is sent
Only for keyed sessions that are not quarantined, and only when the server's verdict for the session changes:
- The first server verdict for a session. It can come from
/v1/check(whichcheck(),getToken()andprotect()
call in the browser), from /v1/evaluate on your server, or from a beacon where the server disagrees with the browser.
- After that, a new delivery only when the class, the behaviour label or the authorization decision changes. A new
score or risk with the same three values sends nothing.
So a page that calls check() on every load gets one delivery for the session, and one more for each change. Two requests that reach the same verdict at the same time still send one. Compare data.provisional with data.verdict.class to see whether the server disagreed with the browser.
Retries
Answer with a 2xx within 10 seconds. A timeout, a network error, a 429 or a 5xx is retried, up to 5 attempts in all, waiting 30 seconds, then 1, 2 and 4 minutes. Any other 4xx is final and not retried. Because of retries, the same id can arrive twice: handle it once. A delivery that never succeeds is not counted as sent, so the next verdict for that session goes out even if it is the same.
caller.scope_violation events use the same URL, secret and signature, but are sent once per grant and day, with no retries.
Check it worked
Use a test key on your site, open it in a new private window and call doubleagent.check() from the browser console. Your endpoint should log one verdict.final event with a valid signature. Calling check() again sends nothing, because the verdict has not changed.
Next: Relabel to fix the label in your tools as well, or Server to verify tokens.