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. |
{{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
{
"vehicle": "{{vehicle}}",
"pickup_location": "{{pickup_location}}",
"agent": "{{agent_name}}",
"time": "{{conversation_time}}"
}Values from your app or website
To send a dynamic variable your website passed when the call started, click Add dynamic variable on the handoff's destination and type the name exactly as your website passes it, for example draft_id. It's added after your template: in a webhook as a field with that exact name, in console and Telegram messages as a line at the end. It's sent empty if the website didn't pass it. The handoff assistant can't add these for you, because it can't see what your website sends. Starting a new handoff, + Add dynamic variables on the empty card takes you straight there.
What your webhook receives (draft_id added as a dynamic variable)
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
{
"vehicle": "2019 Honda Civic, grey",
"pickup_location": "I-35 exit 234, northbound",
"agent": "Genesis",
"time": "2026-09-21 21:13 UTC",
"draft_id": "d_8f3a"
}Other formats
Every value is escaped for its format, whether the avatar collected it or your website passed it. A visitor called O'Brien & Sons "Jr" arrives as:
| Format | Template | Arrives as |
|---|---|---|
| XML | <name>{{name}}</name> | <name>O'Brien & Sons "Jr"</name> |
| Form fields (POST) | name={{name}} | name=O%27Brien+%26+Sons+%22Jr%22 |
| Query parameters (GET) | name={{name}} | ?name=O%27Brien+%26+Sons+%22Jr%22 |
| Plain text | New request from {{name}} | New request from O'Brien & Sons "Jr" |
For form fields and query parameters, write one name=value per line; they're joined with & when sent.
draft_id), and add it to the webhook with Add dynamic variable. It comes back with the answers, attached to the right conversation. Use an ID nobody can guess, or sign it; see signed values. The full example has the server code.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.