# Outgoing webhooks > Receive events in your own systems, and verify that they really came from us. Source: https://welcomeai.dev/docs/webhooks Category: Destinations Updated: 2026-09-11 --- ## The events - `lead.created` — a visitor left their details. - `conversation.completed` — someone marked a conversation closed or escalated. - `document.indexed` — a document finished indexing and can be answered from. - `document.failed` — a document could not be indexed. - `sync.completed` — a connected source finished a sync run. ## Adding an endpoint In **Destinations → Webhooks**, add a public https URL and tick the events you want. We show the signing secret once, when the endpoint is created and whenever you rotate it. Store it then; we only keep a hash of it afterwards. Use **Send a test** to fire a real delivery with sample data: same signature, same retries, same log. ## The envelope We POST JSON with two headers that matter: ``` X-WelcomeAI-Signature: t=1788639323,v1=6f3a... X-WelcomeAI-Event: lead.created ``` The body looks like this: ```json { "id": "evt_2b91Ha0Kd3fQ", "type": "lead.created", "created_at": "2026-09-05T20:15:23.201Z", "org_id": "org_UWzIf5jv8lW2", "data": { "lead_id": "ld_GSTPfNF6SEkE", "email": "grace@example.com" } } ``` ## Verifying a delivery Four things, in this order: 1. Read `t` and `v1` out of the `X-WelcomeAI-Signature` header. 2. Reject anything where `t` is more than **five minutes** old. Every retry is signed again, so a stale timestamp is a replay rather than a slow delivery. 3. Compute HMAC-SHA256 over `.` with your signing secret. 4. Compare it with `v1` in **constant time**, and only then parse the JSON. **Use the raw body, not a re-serialized object.** Any difference in whitespace or key order changes the digest, and most frameworks will hand you a parsed object unless you ask for the bytes. Each recipe below shows how to get them. ### Node ```js const crypto = require("node:crypto"); const express = require("express"); const app = express(); // express.raw, not express.json: the digest is over the bytes that arrived. app.post("/welcomeai", express.raw({ type: "application/json" }), (req, res) => { const header = req.get("X-WelcomeAI-Signature") ?? ""; const parts = Object.fromEntries(header.split(",").map((p) => p.split("=").map((s) => s.trim()))); const t = Number(parts.t); const v1 = parts.v1 ?? ""; const raw = req.body.toString("utf8"); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400); const expected = crypto.createHmac("sha256", process.env.WELCOMEAI_WEBHOOK_SECRET).update(`${t}.${raw}`).digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(v1, "utf8"); // timingSafeEqual throws when the lengths differ, so check that first. if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401); const event = JSON.parse(raw); // Do your work keyed on event.id, so the same delivery arriving twice is a no-op. res.sendStatus(200); }); ``` ### PHP ```php 300) { http_response_code(400); exit; } $expected = hash_hmac('sha256', $t . '.' . $raw, getenv('WELCOMEAI_WEBHOOK_SECRET')); if (!hash_equals($expected, $v1)) { http_response_code(401); exit; } $event = json_decode($raw, true); // Do your work keyed on $event['id']. http_response_code(200); ``` ### Python ```python import hashlib, hmac, os, time from flask import Flask, request app = Flask(__name__) SECRET = os.environ["WELCOMEAI_WEBHOOK_SECRET"].encode() @app.post("/welcomeai") def welcomeai(): raw = request.get_data() # bytes, before anything parses them header = request.headers.get("X-WelcomeAI-Signature", "") parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p) try: t = int(parts.get("t", "")) except ValueError: return "", 400 if abs(time.time() - t) > 300: return "", 400 expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, parts.get("v1", "")): return "", 401 event = request.get_json(force=True) # Do your work keyed on event["id"]. return "", 200 ``` All three were run against a real signature from this product, on a body with accented characters and a nested object, and all three agree: a genuine delivery verifies, a changed body fails, the wrong secret fails, a retry a minute later verifies, and a replay six minutes later is refused. ## Retries We try six times: after 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours. Every attempt is signed again, so a retry never arrives with a stale timestamp. Anything in the 2xx range means delivered. `410 Gone` means stop sending and we do not retry. Everything else is retried, including 4xx, because a receiver that deploys badly should not lose events. An endpoint that fails twenty times in a row is switched off, and you can turn it back on once the receiver is fixed. ## Duplicates The same event id can arrive more than once, for example if your acknowledgement was lost. Treat `id` as the deduplication key.