Webhooks empfangen und prüfen
Aufbau einer Webhook-Nachricht, Header, Prüfung der Signatur mit Beispielen für PHP, Node.js und Python, Wiederholungen, Duplikate und Reihenfolge.
Stand:
Ein Webhook ist eine HTTPS-Adresse im Fremdsystem, an die Revisio bei jedem abonnierten Ereignis eine Nachricht schickt. Eingerichtet wird er unter Verwaltung → API & Webhooks (Anleitung). Je Organisation sind bis zu zehn Webhooks möglich. Welche Ereignisse es gibt, steht unter Webhook-Ereignisse.
Aufbau einer Nachricht#
Revisio sendet POST mit JSON-Inhalt:
POST /hooks/revisio HTTP/1.1
Host: erp.example.com
Content-Type: application/json
User-Agent: Revisio-Webhooks/0.91.0
X-Revisio-Event: order.completed
X-Revisio-Delivery: 01k7e2g4h6j8k0m2n4p6q8r0s2
X-Revisio-Timestamp: 1793689325
X-Revisio-Signature: t=1793689325,v1=5b0e1c3f7a9d2e4b6c8a0f1e3d5c7b9a2e4f6a8c0b1d3e5f7a9c2b4d6e8f0a1c
{
"id": "01k7e1f3g5h7j9k1m3n5p7q9r1",
"event": "order.completed",
"created_at": "2026-11-03T10:22:05+00:00",
"test": false,
"data": {
"order": {
"id": "01k7b2c4d6e8f0g2h4j6k8m0n2",
"number": "A-2026-00012",
"status": "completed"
}
}
}
| Teil | Bedeutung |
|---|---|
id |
Kennung des Ereignisses; bleibt bei jeder Wiederholung gleich und dient zum Erkennen von Duplikaten |
event |
Name des Ereignisses, gleich dem Header X-Revisio-Event |
created_at |
Zeitpunkt, zu dem die Nachricht erstellt wurde (UTC) |
test |
true nur bei Test senden |
data |
Inhalt je Ereignis |
X-Revisio-Delivery |
Kennung der Zustellung; dieselbe über alle automatischen Versuche, neu bei Erneut senden |
X-Revisio-Timestamp |
Unix-Zeit der Signatur, gleich dem Wert t in X-Revisio-Signature |
Nachrichten enthalten nur Kennungen, Nummern, Status, Zeitpunkte und Feldnamen, nie Namen, Adressen oder Texte. Einzelheiten lädt das Fremdsystem über die API. So liegen im Zustellprotokoll und in den Logs des Empfängers keine Kundendaten.
Signatur prüfen#
Jede Nachricht ist mit dem Signaturgeheimnis des Webhooks signiert (whsec_…, wird beim Anlegen einmal angezeigt):
X-Revisio-Signature: t=<Unix-Zeit>,v1=<Hex>
v1 = HMAC-SHA256(Schlüssel = Signaturgeheimnis, Nachricht = "<t>.<Rohtext des Inhalts>")
Das Geheimnis wird vollständig verwendet, einschließlich whsec_. Prüfen Sie so:
- Header mit
^t=(\d+),v1=([a-f0-9]{64})$zerlegen. - Nachrichten verwerfen, deren
tmehr als 5 Minuten von der eigenen Uhrzeit abweicht. Das verhindert, dass abgefangene Nachrichten später erneut eingespielt werden. - HMAC über
t, einen Punkt und den unveränderten Rohtext berechnen. Nicht das bereits geparste und neu serialisierte JSON verwenden, sonst stimmt die Signatur nicht. - Mit dem Wert
v1zeitkonstant vergleichen.
PHP#
<?php
$secret = getenv('REVISIO_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_REVISIO_SIGNATURE'] ?? '';
if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300) {
http_response_code(400);
exit;
}
if (! hash_equals(hash_hmac('sha256', $m[1].'.'.$body, $secret), $m[2])) {
http_response_code(401);
exit;
}
$event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
store_for_processing($event);
http_response_code(204);
Node.js (Express)#
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.REVISIO_WEBHOOK_SECRET;
app.post('/hooks/revisio', express.raw({ type: 'application/json' }), (req, res) => {
const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(req.get('X-Revisio-Signature') ?? '');
if (!match || Math.abs(Date.now() / 1000 - Number(match[1])) > 300) {
return res.sendStatus(400);
}
const expected = crypto.createHmac('sha256', secret).update(`${match[1]}.`).update(req.body).digest();
if (!crypto.timingSafeEqual(expected, Buffer.from(match[2], 'hex'))) {
return res.sendStatus(401);
}
storeForProcessing(JSON.parse(req.body.toString('utf8')));
res.sendStatus(204);
});
Python (Flask)#
import hashlib
import hmac
import json
import os
import re
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["REVISIO_WEBHOOK_SECRET"].encode()
SIGNATURE = re.compile(r"^t=(\d+),v1=([a-f0-9]{64})$")
@app.post("/hooks/revisio")
def revisio_webhook():
match = SIGNATURE.match(request.headers.get("X-Revisio-Signature", ""))
if not match or abs(time.time() - int(match[1])) > 300:
abort(400)
body = request.get_data()
expected = hmac.new(SECRET, match[1].encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, match[2]):
abort(401)
store_for_processing(json.loads(body))
return "", 204
store_for_processing steht jeweils für die eigene Weiterverarbeitung, etwa das Schreiben in eine Warteschlange.
Signatur lokal nachstellen#
Zum Testen des eigenen Empfängers ohne Revisio:
SECRET='whsec_IhrGeheimnis'
BODY='{"id":"01k7e1f3g5h7j9k1m3n5p7q9r1","event":"webhook.test","created_at":"2026-11-03T10:22:05+00:00","test":true,"data":{}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -X POST "https://erp.example.com/hooks/revisio" \
-H "Content-Type: application/json" \
-H "X-Revisio-Event: webhook.test" \
-H "X-Revisio-Signature: t=$T,v1=$SIG" \
--data-raw "$BODY"
Antworten und Wiederholungen#
- Jede Antwort mit Status 2xx gilt als zugestellt. Der Inhalt der Antwort ist beliebig; die ersten 2 KB stehen im Zustellprotokoll.
- Revisio wartet höchstens 10 Sekunden auf die Antwort (Verbindungsaufbau 5 Sekunden). Nehmen Sie die Nachricht an, speichern Sie sie und antworten Sie sofort; die eigentliche Verarbeitung gehört in einen Hintergrundprozess.
- Weiterleitungen (3xx) werden nicht verfolgt und gelten als Fehler. Tragen Sie die endgültige Adresse ein.
- Bei Fehlern, Zeitüberschreitung oder Status außerhalb von 2xx versucht Revisio es erneut, insgesamt bis zu fünfmal:
| Versuch | Abstand zum vorigen |
|---|---|
| 1 | sofort |
| 2 | 1 Minute |
| 3 | 5 Minuten |
| 4 | 30 Minuten |
| 5 | 2 Stunden |
- Nach dem fünften Fehlversuch ist die Zustellung fehlgeschlagen. Im Zustellprotokoll lässt sie sich mit
Erneut senden wiederholen, mit derselben Ereignis-
id. - Nach fünf fehlgeschlagenen Zustellungen in Folge schaltet Revisio den Webhook ab; ausstehende Zustellungen werden nicht mehr gesendet. Eine erfolgreiche Zustellung setzt den Zähler zurück.
- Test senden schickt genau einen Versuch, auch an einen pausierten Webhook, und zählt nicht als Fehlschlag.
Empfang robust bauen#
Duplikate erkennen. Dieselbe Nachricht kann mehr als einmal ankommen, etwa wenn die Antwort des Empfängers
verloren geht oder jemand Erneut senden klickt. Speichern Sie die id verarbeiteter Ereignisse und überspringen
Sie bekannte.
Reihenfolge nicht voraussetzen. Nachrichten werden parallel zugestellt und bei Fehlern später wiederholt. Ein
order.status_changed kann vor dem zugehörigen order.created eintreffen. Laden Sie im Zweifel den aktuellen Stand
über die API und vergleichen Sie updated_at.
Unbekanntes ignorieren. Neue Ereignisse und Felder kommen hinzu, ohne dass sich die Version ändert. Beantworten Sie unbekannte Ereignisse mit 2xx und ignorieren Sie unbekannte Felder.
Echtheit über die Signatur prüfen, nicht über die IP-Adresse des Absenders. Die Adressen der Server können sich ändern.
Testnachrichten erkennen. Bei Test senden ist event webhook.test und test true. data enthält dann
nur einen Hinweistext und die Kennung des Webhooks.
Anforderungen an die Empfänger-Adresse#
- nur
https://, mit gültigem Zertifikat - öffentlich erreichbar; Adressen im eigenen Netz (
localhost,10.x,172.16–31.x,192.168.x,*.local,*.internalusw.) lehnt Revisio ab, auch wenn ein öffentlicher Name auf eine solche Adresse zeigt - keine Zugangsdaten in der Adresse (
https://benutzer:passwort@…); zur zusätzlichen Absicherung genügt die Signatur oder ein schwer zu ratender Pfad
Revisio prüft die Adresse beim Speichern und vor jeder Zustellung erneut.
Fehlersuche#
| Im Zustellprotokoll | Ursache |
|---|---|
| Empfänger antwortete mit HTTP 401/403 | Signatur- oder Zugriffsprüfung des Empfängers schlägt fehl, Geheimnis vergleichen |
| Empfänger antwortete mit HTTP 301/302 | Adresse leitet weiter, endgültige Adresse eintragen |
| Zeitüberschreitung beim Empfänger | Antwort dauert länger als 10 Sekunden |
| TLS-Verbindung fehlgeschlagen | Zertifikat abgelaufen, selbst signiert oder Kette unvollständig |
| Host nicht erreichbar | DNS-Name nicht auflösbar |
| Die Adresse zeigt auf ein internes Netz | Ziel ist nicht öffentlich erreichbar |
| Nicht gesendet: Endpunkt ist deaktiviert | Webhook pausiert oder automatisch abgeschaltet |
Nach Geheimnis erneuern gilt nur noch das neue Geheimnis. Tragen Sie es im Empfänger ein, bevor das nächste Ereignis kommt, oder pausieren Sie den Webhook kurz.