LanguageENES
Destinations

Outgoing webhooks

Receive events in your own systems, and verify that they really came from us.

Updated 2026-09-11View .md

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 <t>.<raw body> 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
<?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

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.