Documentation

Inbound webhooks

Antwoorden van je gekoppelde WhatsApp-nummer realtime ontvangen. Drop-in compatibel met TextMeBot — bestaande integraties die type / from / from_name / to / file / message verwerken werken zonder aanpassingen.

1. Endpoint configureren

Open /admin/webhook in het customer portal:

  1. Plak je publieke HTTPS URL.
  2. Klik Opslaan — er wordt automatisch een 64-char HMAC-secret gegenereerd. Kopieer hem direct, hij wordt nadien gemaskeerd.
  3. Klik Test fire om een sample-payload naar je endpoint te sturen.

2. Payload (TextMeBot-compatibel)

Bij elk inkomend bericht doet TextMeFlow een POST met content-type application/json en deze body:

{
  "type": "text",
  "from": "32472932208",
  "from_name": "Jan Janssens",
  "to": "32499123456",
  "file": "null",
  "message": "Bedankt voor de bevestiging!",
  "from_me": false,
  "wa_message_id": "3EB0A1B2C3D4E5F6",
  "message_id": null
}
VeldTypeInhoud
typestringtext, image, audio, video, document of location
fromstringAfzender, alleen cijfers (geen +)
from_namestringWhatsApp pushName, mogelijk leeg
tostringJouw gekoppelde nummer, alleen cijfers
filestringVoor text-berichten letterlijk de string "null" (matcht TextMeBot). Voor image, video, audio en document de URL van het bestand, door TextMeFlow gehost. Een foto zonder bijschrift komt binnen met een lege message.
messagestringTekst of bijschrift; voor document de filename, voor location de string "lat,lng"
from_mebooleantrue voor een bericht dat jíj verstuurde — via de API óf met de hand in WhatsApp. Zo zie je het volledige gesprek in je eigen systeem. Bij true is from jouw nummer en to dat van de klant.
wa_message_idstringHet WhatsApp-id van het bericht, in beide richtingen. Uniek per bericht; bruikbaar als ontdubbelsleutel.
message_idstring | nullEnkel bij from_me: true én verstuurd via de API: het message_id dat je bij het versturen terugkreeg. Daaraan herken je je eigen verzending en sla je ze niet dubbel op. null voor handmatig getikte berichten en voor alles wat binnenkomt.

3. Headers

HeaderInhoud
Content-Typeapplication/json
User-AgentTextMeFlow-Webhook/1.0
X-TextMeFlow-Event-IdUUID per bericht — gebruik dit als idempotency-key, retries hergebruiken hetzelfde id.
X-TextMeFlow-Signaturesha256=<hex> — HMAC-SHA256 van de raw body met je webhook-secret.
X-TextMeFlow-DeliveryUUID per delivery-poging, handig voor log-grep.

4. Signature verifiëren

Bereken HMAC-SHA256 over de raw request body (niet over een geparseerd object — JSON-encoder verschillen breken anders je hash) en vergelijk constant-time met de sha256=… waarde uit de header.

PHP

// $secret = 'tmf_…' uit /admin/webhook
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
$received = $_SERVER['HTTP_X_TEXTMEFLOW_SIGNATURE'] ?? '';

if (! hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}
$body = json_decode($raw, true);

Node.js (Express)

import crypto from 'node:crypto';
import express from 'express';

const app = express();
// Capture raw body for HMAC; can't run after express.json() ate it.
app.use(express.raw({ type: 'application/json' }));

app.post('/textmeflow-inbound', (req, res) => {
  const raw = req.body; // Buffer
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.TMF_WEBHOOK_SECRET)
    .update(raw)
    .digest('hex');

  const received = req.get('X-TextMeFlow-Signature') ?? '';
  if (
    expected.length !== received.length ||
    !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))
  ) {
    return res.sendStatus(401);
  }

  const body = JSON.parse(raw.toString('utf8'));
  // …handle body…
  res.sendStatus(200);
});

Python (Flask)

import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['TMF_WEBHOOK_SECRET'].encode()

@app.post('/textmeflow-inbound')
def inbound():
    raw = request.get_data()
    expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get('X-TextMeFlow-Signature', '')
    if not hmac.compare_digest(expected, received):
        abort(401)
    body = request.get_json()
    # …handle body…
    return '', 200

5. Retry-policy

Een delivery is gelukt bij elke 2xx response binnen 10s. Andere uitkomsten:

ResponseGedrag
2xx✅ delivered, geen retry.
408 / 429 / 5xx🔁 retry — max 5 keer met backoff 1m, 5m, 30m, 2h, 6h.
andere 4xx❌ failed, geen retry. Een 401 betekent meestal dat je secret niet meer matcht — controleer en fix bij jouw kant.
timeout / netwerk🔁 retry zoals 5xx.

Na 6 totale pogingen (eerste + 5 retries) wordt de delivery als failed gemarkeerd. Status van elke poging zie je in /admin/webhook.

6. Migreren vanaf TextMeBot

Geen code-wijzigingen nodig — payload-shape is identiek. Eén actie:

  1. Plak je bestaande TextMeBot-endpoint URL in /admin/webhook.
  2. Voeg een signature-check toe (TextMeBot heeft die niet — verplichte upgrade voor productie). Zie de snippets hierboven.

Klaar om te ontvangen?

Configureer je endpoint in /admin/webhook en stuur een test-payload met één klik.