Integrations
Webhooks
Receive what your avatar collected in your own systems, and check that every request really came from Selvia AI.
What gets sent
A data handoff runs after each conversation. When it applies, it fills in the details it was set up to collect and sends them to your webhook. You set the handoff up in the console, in plain words, and choose the format and the exact message.
- •Formats: JSON, XML, plain text or form fields (POST), or query parameters (GET). You can add your own headers.
- •Your template is the contract. Its shape never changes; each value is escaped for the format, so a quote or a
<in a visitor's answer can't break your parser. - •A detail the avatar couldn't find is sent empty, unless the handoff marks it as needed, in which case nothing is sent for that conversation.
Values you can use in a template
| Write | Value |
|---|---|
{{vehicle}} | A detail the handoff collects: whatever you named when setting it up. |
{{visitor.draft_id}} | A dynamic variable your website passed when the call started. Empty if it wasn't passed. |
{{agent_name}} | The name of the agent that had the conversation. |
{{rule_name}} | The name of the data handoff. |
{{conversation_time}} | When the conversation started, in UTC. |
{{visitor_turns}} | How many times the visitor spoke. |
A JSON template
{
"draft_id": "{{visitor.draft_id}}",
"vehicle": "{{vehicle}}",
"pickup_location": "{{pickup_location}}",
"agent": "{{agent_name}}",
"time": "{{conversation_time}}"
}What your webhook receives
POST /your-webhook HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: SelviaAI-DataHandoff/1.0
X-Selvia-Timestamp: 1790025248
X-Selvia-Signature: sha256=5f0c…e91a
X-Selvia-Delivery: 4812
{
"draft_id": "d_8f3a",
"vehicle": "2019 Honda Civic, grey",
"pickup_location": "I-35 exit 234, northbound",
"agent": "Genesis",
"time": "2026-09-21 21:13 UTC"
}Matching a conversation to a record in your systems: have your website pass a reference as a dynamic variable (for example
draft_id), and put {{visitor.draft_id}} in the template. It comes back with the answers, attached to the right conversation. Use an ID nobody can guess, or sign it; see signed values.Verify the signature
Every request carries a signature made with your webhook's secret (it starts with whsec_; you can view or replace it on the webhook in the console). Check it before trusting the request:
| Header | Contains |
|---|---|
X-Selvia-Timestamp | When the request was sent, in Unix seconds. |
X-Selvia-Signature | sha256= followed by the hex HMAC-SHA256. |
X-Selvia-Delivery | The delivery ID. The same on every retry of the same delivery. |
- •Build the text
<timestamp>.<payload>. The payload is the raw request body for POST. For GET it's the query string without the leading?(if your URL has its own query parameters, only the part after them). - •Compute HMAC-SHA256 of that text, using the whole secret,
whsec_included, as the key. - •Compare
sha256=+ the hex result with the header, using a constant-time comparison. - •Reject timestamps more than 5 minutes from your clock. Every attempt, retries included, is signed fresh.
Node.js (Express)
const crypto = require("crypto");
const express = require("express");
const app = express();
// Keep the raw body: the signature covers the exact bytes that were sent.
app.post("/your-webhook", express.raw({ type: "*/*" }), (req, res) => {
const ts = req.get("X-Selvia-Timestamp") || "";
const sig = req.get("X-Selvia-Signature") || "";
const body = req.body.toString("utf8");
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET) // whsec_… from the console
.update(ts + "." + body)
.digest("hex");
const fresh = /^\d+$/.test(ts) && Math.abs(Date.now() / 1000 - Number(ts)) < 300;
const valid = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!fresh || !valid) return res.status(401).end();
// Skip it if you've already processed req.get("X-Selvia-Delivery"), then:
res.status(200).end();
});Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"].encode() # whsec_… from the console
@app.post("/your-webhook")
def webhook():
ts = request.headers.get("X-Selvia-Timestamp", "")
sig = request.headers.get("X-Selvia-Signature", "")
body = request.get_data(as_text=True) # the raw body, before parsing
expected = "sha256=" + hmac.new(SECRET, f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
if not ts.isdigit() or abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(sig, expected):
abort(401)
# Skip it if you've already processed request.headers["X-Selvia-Delivery"], then:
return "", 200PHP
<?php
$secret = getenv('WEBHOOK_SECRET'); // whsec_… from the console
$ts = $_SERVER['HTTP_X_SELVIA_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_SELVIA_SIGNATURE'] ?? '';
$body = file_get_contents('php://input'); // the raw body, before parsing
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300 || !hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
// Skip it if you've already processed $_SERVER['HTTP_X_SELVIA_DELIVERY'], then:
http_response_code(200);Responses, retries and duplicates
| Your webhook | What happens |
|---|---|
| Answers 2xx within 10 seconds | Delivered. |
| Times out, can't be reached, or answers 408, 425, 429 or 5xx | Tried again after 1 minute, 5 minutes, 30 minutes and 2 hours: 5 attempts in all. |
| Answers another 4xx, or redirects | Not retried, and the webhook is marked as needing attention. Redirects aren't followed: use the final URL. |
| Fails 10 deliveries in a row | Paused. A successful Send test from the console resumes it. |
- •Answer quickly and do slow work afterwards; the 10-second limit covers your whole response.
- •A delivery can occasionally arrive twice. Keep the
X-Selvia-DeliveryIDs you've processed and skip repeats. - •Answer a bad signature with 401. Don't answer “record not found” with a 4xx: accept the request and handle it on your side, or the webhook will be marked as needing attention.
- •Webhooks must be on the public internet (https:// recommended). Addresses inside a private network can't receive them.
Every delivery is logged in the console with its status, so you can see what was sent and what your webhook answered.