Skip to content
API docsOpen the app
ContentsWebhooks and verifying signatures

Webhooks and verifying signatures

Wagent posts events to your HTTPS endpoint as they happen, signed so you can prove they came from Wagent.

Endpoints#

Add a webhook on the Developers screen. Each endpoint belongs to one agent, so message events follow that agent's rights: an event about a chat the agent cannot see is never sent. Adding one needs the owner's WhatsApp code.

The URL must be https on a public address. Wagent follows no redirects and reads at most a few kilobytes of your answer. The signing secret (whsec_…) is shown once; Rotate signs with both the old and new secret for 24 hours.

What a delivery looks like#

A POST with a JSON body { id, type, created_at, workspace_id, env, data } and three Standard Webhooks headers:

Signature headers
HeaderValue
webhook-idThe event id, evt_ then a UUID. The same on every retry and replay: use it to skip duplicates.
webhook-timestampUnix seconds when this attempt was signed.
webhook-signatureSpace-separated v1,<base64> HMAC-SHA256 signatures of {webhook-id}.{webhook-timestamp}.{raw body}.

Verify each event#

Check the signature over the raw body before parsing it, refuse timestamps more than 5 minutes from your clock, answer 2xx quickly and skip ids you have already handled. This is the function Wagent tests its own signatures with:

import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';

export function verify(secret: string, h: Record<string, string>, raw: string) {
  const id = h['webhook-id'], ts = Number(h['webhook-timestamp']);
  if (!id || !Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
  const want = createHmac('sha256', Buffer.from(secret.slice(6), 'base64')).update(`${id}.${ts}.${raw}`).digest();
  return (h['webhook-signature'] ?? '').split(' ').some((p) => {
    const [v, sig] = p.split(',');
    const got = Buffer.from(sig ?? '', 'base64');
    return v === 'v1' && got.length === want.length && timingSafeEqual(got, want);
  });
}

const seen = new Set(); // keep handled event ids in your database in production

createServer((req, res) => {
  let raw = '';
  req.setEncoding('utf8');
  req.on('data', (chunk) => (raw += chunk));
  req.on('end', () => {
    if (!verify(process.env.WAGENT_WEBHOOK_SECRET, req.headers, raw)) return void res.writeHead(401).end();
    res.writeHead(200).end(); // answer within 15 seconds, then do the work
    const id = req.headers['webhook-id'];
    if (seen.has(id)) return; // a retry or replay of an event you already handled
    seen.add(id);
    const event = JSON.parse(raw); // { id, type, created_at, workspace_id, env, data }
  });
}).listen(3000);

Retries#

  • Any 2xx within 15 seconds marks the event delivered. Anything else, a redirect or a timeout is a failure.
  • Wagent tries 6 times over 24 hours with the same event id: at once, then about 1 minute, 10 minutes, 1 hour, 6 hours and 24 hours later.
  • Keep events until delivered (on by default): after the sixth failure the event stays queued and is tried every 6 hours until it is delivered or 30 days old. Turned off, it is marked failed.
  • A 410 answer turns the endpoint off. A 429 pauses the whole endpoint for its Retry-After (at most an hour).
  • You can replay any event from the Developers screen or with POST /v1/events/{id}/replay. A replay delivers the same event id again; it never repeats the action behind it.

List your endpoint's deliveries with GET /v1/events. The list has ids, types, statuses and tries, never message text.