Outgoing webhooks
Receive events in your own systems, and verify that they really came from us.
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.createdThe body looks like this:
{
"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:
- Read
tandv1out of theX-WelcomeAI-Signatureheader. - Reject anything where
tis more than five minutes old. Every retry is signed again, so a stale timestamp is a replay rather than a slow delivery. - Compute HMAC-SHA256 over
<t>.<raw body>with your signing secret. - Compare it with
v1in 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
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
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WELCOMEAI_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $piece) {
[$k, $v] = array_pad(explode('=', trim($piece), 2), 2, '');
$parts[$k] = $v;
}
$t = isset($parts['t']) ? (int) $parts['t'] : 0;
$v1 = $parts['v1'] ?? '';
if ($t === 0 || abs(time() - $t) > 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
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 "", 200All 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.