Skip to main content

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.

Plan

Webhook data sources need the Scale plan or higher.

Setup​

  1. Create a data source with your HTTPS URL and store the returned signing_secret (see Data source API).

  2. 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" }] }
    }
  3. Declare every key you fill in the screen's data (here slots, 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:

HeaderValue
Content-Typeapplication/json
User-AgentSendSeven-Flows/1.0
X-SendSeven-Eventwhatsapp_flow.data_exchange
X-SendSeven-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>. After a secret rotation, for 24 hours: t=…,v1=<new>,v1=<old>.
Authorization, X-Api-Key, Accept-LanguageOnly 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"
}
FieldMeaning
actionINIT (flow opened), BACK (contact went back), or data_exchange (contact submitted a screen).
screenThe screen the request came from. Empty on INIT.
target_screenThe screen SendSeven is about to show and that your binding belongs to.
dataThe values the contact submitted on screen, already validated.
binding_id, paramsFrom your binding, so one URL can serve several bindings.
session_idThe flow session. Stable for one send; use it to correlate calls.
contact_idThe SendSeven contact, if known.
languageThe 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."
}
KeyEffect
dataMapped 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.
screenOptional 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_messageKeeps 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 2xx is 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_exchange call. 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.

CodeCause
timeoutNo complete answer within timeout_ms.
http_statusYour server answered with a non-2xx status (including redirects).
invalid_jsonThe body was not a JSON object.
response_too_largeThe body exceeded 256 KB.
connection_failedTLS or connection error.
dns_failed, blocked_address, blocked_url, https_requiredThe URL could not be resolved, points to a non-public address, or is not HTTPS.
unsigned_sourceThe data source has no signing secret. Rotate it to get one.
data_source_missingThe data source was deleted or deactivated.
screen_not_allowedYour 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:

  1. Rotate and store the new signing_secret.
  2. Deploy your server with the new secret within 24 hours. Requests verify with the old secret until then and with the new one afterwards.
  3. 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.