Custom Webhook Data Source
A webhook data source lets a dynamic flow load data from your backend: free appointment slots, a stock check, a quote, a customer lookup. SendSeven still runs the WhatsApp endpoint, encryption and validation. Your server only receives a small, signed JSON request and answers with plain JSON.
Webhook data sources need the Scale plan or higher.
Setup
-
Create a data source with your HTTPS URL and store the returned
signing_secret(see Data source API). -
Bind it to a screen in your builder doc:
{
"id": "slots",
"kind": "webhook",
"screen_id": "PICK_SLOT",
"data_source_id": "e4b1a0c2-…",
"params": { "service": "haircut" },
"outputs": { "slots": "available_slots" },
"fallback": { "slots": [{ "id": "call_me", "title": "Bitte rufen Sie mich an" }] }
} -
Declare every key you fill in the screen's
data(hereslots, an array of{id, title}).
SendSeven calls your URL whenever the bound screen is about to be shown: when the flow opens (if it is the entry screen), when the contact submits the previous screen, or when they go back to a screen with refresh_on_back.
Request
POST to your URL with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SendSeven-Flows/1.0 |
X-SendSeven-Event | whatsapp_flow.data_exchange |
X-SendSeven-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. After a secret rotation, for 24 hours: t=…,v1=<new>,v1=<old>. |
Authorization, X-Api-Key, Accept-Language | Only if you configured them on the data source |
Body (compact JSON):
{
"event": "whatsapp_flow.data_exchange",
"action": "data_exchange",
"screen": "PICK_SERVICE",
"target_screen": "PICK_SLOT",
"data": { "service": "haircut", "date": "2026-10-08" },
"binding_id": "slots",
"params": { "service": "haircut" },
"session_id": "5b0e2c1a-…",
"flow_definition_id": "0f9e8d7c-…",
"version_id": "a1b2c3d4-…",
"contact_id": "c0ffee00-…",
"language": "de",
"sent_at": "2026-10-03T09:15:02.123456+00:00"
}
| Field | Meaning |
|---|---|
action | INIT (flow opened), BACK (contact went back), or data_exchange (contact submitted a screen). |
screen | The screen the request came from. Empty on INIT. |
target_screen | The screen SendSeven is about to show and that your binding belongs to. |
data | The values the contact submitted on screen, already validated. |
binding_id, params | From your binding, so one URL can serve several bindings. |
session_id | The flow session. Stable for one send; use it to correlate calls. |
contact_id | The SendSeven contact, if known. |
language | The flow's language (meta.language). |
Verifying the signature
The signature is HMAC-SHA256(secret, "<t>.<raw body>"), hex-encoded. Always verify against the raw request body, before parsing JSON, and reject timestamps older than 5 minutes.
The header can contain more than one v1. For 24 hours after you rotate the secret, SendSeven signs with the new and the old secret: t=1759482902,v1=<new>,v1=<old>. Accept the request if any v1 matches your secret. Do not split the header into a key/value map, because that keeps only one v1.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const SECRET = process.env.SENDSEVEN_FLOW_SECRET; // whfs_...
const app = express();
function verify(rawBody, header, toleranceSeconds = 300) {
if (!header) return false;
let t = 0;
const signatures = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't') t = parseInt(value, 10);
else if (key === 'v1') signatures.push(value); // several during a rotation
}
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const want = Buffer.from(
crypto.createHmac('sha256', SECRET).update(`${t}.`).update(rawBody).digest('hex'),
'utf8',
);
return signatures.some((sig) => {
const given = Buffer.from(sig, 'utf8');
return given.length === want.length && crypto.timingSafeEqual(given, want);
});
}
app.post('/sendseven/flows', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('X-SendSeven-Signature'))) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
if (event.binding_id === 'slots') {
return res.json({
data: {
available_slots: [
{ id: '2026-10-08T09:00', title: 'Do, 08.10. 09:00' },
{ id: '2026-10-08T11:30', title: 'Do, 08.10. 11:30' },
],
},
});
}
res.json({ data: {} });
});
app.listen(3000);
Python (FastAPI)
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, HTTPException, Request
SECRET = os.environ["SENDSEVEN_FLOW_SECRET"] # whfs_...
app = FastAPI()
def verify(raw_body: bytes, header: str | None, tolerance: int = 300) -> bool:
if not header:
return False
ts, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
ts = value
elif key == "v1":
signatures.append(value) # several during a rotation
try:
ts = int(ts)
except (TypeError, ValueError):
return False
if abs(time.time() - ts) > tolerance:
return False
expected = hmac.new(SECRET.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
@app.post("/sendseven/flows")
async def flow_data(request: Request):
raw = await request.body()
if not verify(raw, request.headers.get("X-SendSeven-Signature")):
raise HTTPException(status_code=401)
event = json.loads(raw)
if event["binding_id"] == "slots":
slots = find_free_slots(event["data"].get("date")) # your code
if not slots:
return {"error_message": "An diesem Tag ist leider nichts mehr frei."}
return {"data": {"available_slots": [{"id": s.id, "title": s.label} for s in slots]}}
return {"data": {}}
Response
Answer with HTTP 200 and a JSON object. All keys are optional; unknown keys are ignored.
{
"data": { "available_slots": [{ "id": "2026-10-08T09:00", "title": "Do, 08.10. 09:00" }] },
"screen": "PICK_SLOT_EXPRESS",
"error_message": "An diesem Tag ist leider nichts mehr frei."
}
| Key | Effect |
|---|---|
data | Mapped through the binding's outputs, then filtered to the keys declared in the target screen's data. Anything else is dropped. Values must match the declared types (strings, arrays of {id, title}, booleans …), or WhatsApp rejects the screen. |
screen | Optional reroute. It is honoured only if the screen is in the next_screens of the action the contact just submitted. Otherwise it is ignored and the binding counts as failed (screen_not_allowed). Only one reroute per request. |
error_message | Keeps the contact on the current screen and shows this text (trimmed to 300 characters). Use it for "no slots left", "customer number not found" and similar. |
Rules
- Timeout: 100–8000 ms per data source, default 2500 ms. The whole request must finish within WhatsApp's 10-second limit, so keep it short.
- Status: anything other than
2xxis a failure. Redirects are not followed. - Size: responses over 256 KB are a failure.
- Format: the body must be a JSON object. An array, plain text or invalid JSON is a failure.
- Network: the URL must be public HTTPS. Private, loopback and internal addresses are refused, including via DNS.
- Idempotency: a request can be repeated (for example when the contact goes back). Treat calls as reads; do not book an appointment on a
data_exchangecall. Book it when the flow is completed.
When a call fails, SendSeven uses the binding's fallback data and the contact can continue. Always set a sensible fallback.
Errors
The latest failure is shown in last_error on the data source and in the flow's analytics.
| Code | Cause |
|---|---|
timeout | No complete answer within timeout_ms. |
http_status | Your server answered with a non-2xx status (including redirects). |
invalid_json | The body was not a JSON object. |
response_too_large | The body exceeded 256 KB. |
connection_failed | TLS or connection error. |
dns_failed, blocked_address, blocked_url, https_required | The URL could not be resolved, points to a non-public address, or is not HTTPS. |
unsigned_source | The data source has no signing secret. Rotate it to get one. |
data_source_missing | The data source was deleted or deactivated. |
screen_not_allowed | Your screen was not one of the allowed next screens. |
Rotating the secret
POST /whatsapp-flow-data-sources/{id}/rotate-secret returns a new signing_secret once. The old secret stays valid for 24 hours. The response shows when it expires in previous_secret_expires_at.
During those 24 hours each request carries two signatures, v1=<new>,v1=<old>. A verifier that accepts any matching v1 keeps working with either secret:
- Rotate and store the new
signing_secret. - Deploy your server with the new secret within 24 hours. Requests verify with the old secret until then and with the new one afterwards.
- After 24 hours SendSeven signs with the new secret only.
Notes:
- Rotating again within the 24 hours ends the older secret's overlap at once. Only the latest two secrets are ever valid.
- Setting a secret yourself with
PATCH(secret) replaces the current one immediately, with no overlap. Use this when a secret has leaked.