Darstellung
Webhooks
Statt zu pollen meldet ratum Statuswechsel per POST an deinen HTTPS-Endpoint.
Endpoints einrichten
Im GUI unter Profil → Webhooks oder per API (Scope webhooks:manage):
http
POST /api/v1/webhooks HTTP/1.1
Authorization: Bearer ratum_live_…
Content-Type: application/json
{ "url": "https://example.com/webhooks/ratum", "events": ["document.completed", "document.failed"] }Die Antwort enthält das Signier-Secret (secret, beginnt mit ratum_whsec_) genau einmal. Speichere es sofort; ratum zeigt es nicht wieder an.
| Route | Zweck |
|---|---|
GET /webhooks · GET /webhooks/{id} | Endpoints lesen |
PATCH /webhooks/{id} | url, events oder enabled ändern (Reaktivieren nach Abschaltung) |
DELETE /webhooks/{id} | Endpoint löschen |
POST /webhooks/{id}/rotate-secret | neues Secret, gilt sofort für alle folgenden Zustellungen, auch Retries |
POST /webhooks/{id}/test | Test-Event webhook.ping senden (ignoriert Abo und Abschaltung) |
GET /webhooks/{id}/deliveries | Zustell-Log (30 Tage) |
- Höchstens 3 Endpoints pro Konto (sonst
409 conflict). Webhooks gibt es nur in bezahlten Tarifen; per API-Key greift das schon überplan_required, im GUI antwortet die Registrierung ohne bezahlten Tarif mit402. - Nur
https://-URLs, deren Host öffentlich auflöst. Private, interne, Loopback- und Link-Local-Adressen werden mit422 webhook_url_rejectedabgelehnt, bei der Registrierung und bei jeder Zustellung erneut geprüft. Redirects werden nicht verfolgt.
Ereignisse
| Event | Auslöser |
|---|---|
document.sent | Dokument versendet (Status pending) |
document.failed | Vorbereitung gescheitert (Status failed, failure_code im Payload) |
signer.activated | Signer ist erstmals an der Reihe; Erinnerungen und Link-Abrufe lösen es nicht erneut aus |
signer.signed | Signer hat signiert |
signer.declined | Signer hat abgelehnt |
signer.expired | Frist des Signers abgelaufen |
document.completed | alle haben signiert, PDF versiegelt (Status signed) |
webhook.ping | nur über «Test-Event senden»; nicht abonnierbar |
Payload
Die Payload ist bewusst schlank: nur IDs und Status, keine Namen, E-Mail-Adressen oder Signier-Links. Details holst du mit GET /documents/{id}.
json
{
"id": "evt_3f9c2a7e1b3d4c8e9f1a2b3c4d5e6f70",
"type": "signer.signed",
"created_at": "2026-10-05T09:12:44.123456+00:00",
"livemode": true,
"data": {
"document_id": "6f1c…",
"signer_id": "a7d2…",
"status": "pending"
}
}data.statusist der Dokument-Status zum Zeitpunkt des Events.signer_idsteht nur beisigner.*-Events,failure_codenur beidocument.failed. Einwebhook.pingenthält nurdata.endpoint_id.idist eindeutig pro Ereignis und steht zusätzlich im HeaderRatum-Event-Id, der Typ inRatum-Event-Type. Die Zustellung ist at-least-once: dieselbeidkann mehrfach ankommen; verarbeite sie nur einmal.livemode: falsekennzeichnet Ereignisse zu Dokumenten aus dem Test-Modus.- Die Reihenfolge der Zustellung ist nicht garantiert. Massgeblich ist der Status aus
GET /documents/{id}.
Zustellung und Retries
- Antworte mit
2xx. ratum wartet höchstens 5 s auf den Verbindungsaufbau und 15 s insgesamt; langsame Verarbeitung gehört in eine eigene Queue. - Retry bei
5xx,408,429, Timeout sowie Verbindungs- und DNS-Fehlern, nach 1 min, 5 min, 30 min, 2 h, 5 h, 8 h und 8 h (8 Versuche, insgesamt etwa 24 Stunden). Danach gilt die Zustellung alsfailed. - Kein Retry bei
2xx(Erfolg) und bei allen anderen Antworten (übrige4xx,3xx) — sie sind sofortfailed. - Nach 3 endgültig gescheiterten Zustellungen in Folge deaktiviert ratum den Endpoint und benachrichtigt dich per E-Mail. Ein Erfolg dazwischen setzt den Zähler zurück. Reaktivieren mit
PATCH /webhooks/{id}("enabled": true). - Das Zustell-Log (Status, Versuche, letzter Statuscode) siehst du 30 Tage lang im GUI und unter
GET /webhooks/{id}/deliveries.
Signatur prüfen
Jeder Webhook trägt den Header:
Ratum-Signature: t=1760000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdv1 ist HMAC-SHA256 über "<t>.<roher Body>" mit dem Secret des Endpoints (der ganze String inklusive ratum_whsec_), hex-kodiert in Kleinbuchstaben. Prüfe jeden Webhook, bevor du ihm traust:
tund allev1-Werte aus dem Header lesen. Heute schickt ratum genau eine Signatur; einer der Werte muss passen, so bleibt dein Code auch bei künftig mehreren gültig.- Zeitstempel ablehnen, wenn er mehr als 5 Minuten von deiner Uhr abweicht (Replay-Schutz).
- HMAC über den unveränderten Body berechnen (vor dem JSON-Parsen) und zeitkonstant vergleichen.
Mit dem SDK
@ratum/sdk (verifyWebhookSignature({ payload, header, secret })) und ratum (verify_webhook_signature(payload, header, secret)) bringen die Prüfung fertig mit. Beide sind veröffentlicht: npm install @ratum/sdk (siehe Quickstart) bzw. pip install ratum (siehe Quickstart). Der Code unten funktioniert ohne SDK.
ts
// Prüft die Signatur eines ratum-Webhooks (Header `Ratum-Signature: t=<unix>,v1=<hex>`).
// Eingebunden in docs.ratum.ch/guides/webhooks; Tests: verify-webhook.test.ts.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 5 * 60
/**
* `rawBody` ist der unveränderte Request-Body (vor JSON.parse — schon ein umformatiertes
* Leerzeichen bricht die Signatur). Wirft, wenn die Signatur nicht passt oder der Zeitstempel
* ausserhalb von ±5 Minuten liegt (Replay-Schutz).
*/
export function verifyRatumSignature(
rawBody: string,
header: string | undefined,
secret: string,
now: number = Math.floor(Date.now() / 1000),
): void {
if (!header) throw new Error('Ratum-Signature fehlt')
let timestamp: number | undefined
const signatures: string[] = []
for (const part of header.split(',')) {
// indexOf statt split('=', 2): split schneidet "v1=<sig>=müll" still auf "<sig>" ab.
const trimmed = part.trim()
const eq = trimmed.indexOf('=')
const key = eq < 0 ? trimmed : trimmed.slice(0, eq)
const value = eq < 0 ? undefined : trimmed.slice(eq + 1)
// Nur ASCII-Ziffern: `Number('1e3')` oder `Number(' 1')` würden sonst still akzeptiert.
if (key === 't' && value !== undefined && /^\d+$/.test(value)) timestamp = Number(value)
// Mehrere v1-Werte sind erlaubt; einer muss passen.
else if (key === 'v1' && value) signatures.push(value)
}
if (timestamp === undefined || signatures.length === 0) {
throw new Error('Ratum-Signature unvollständig')
}
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
throw new Error('Zeitstempel ausserhalb des Toleranzfensters')
}
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest()
// Exakt 64 Kleinbuchstaben-Hex: `Buffer.from(…, 'hex')` bricht sonst beim ersten Nicht-Hex-Zeichen
// still ab und akzeptierte z. B. "<signatur>zz" (gleiches Verhalten wie verify_webhook.py).
const valid = signatures.some(
(sig) => /^[0-9a-f]{64}$/.test(sig) && timingSafeEqual(Buffer.from(sig, 'hex'), expected),
)
if (!valid) throw new Error('Signatur ungültig')
}py
"""Prüft die Signatur eines ratum-Webhooks (Header ``Ratum-Signature: t=<unix>,v1=<hex>``).
Eingebunden in docs.ratum.ch/guides/webhooks; Tests: test_verify_webhook.py.
"""
import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 5 * 60
def verify_ratum_signature(
raw_body: bytes, header: str | None, secret: str, now: int | None = None
) -> None:
"""``raw_body`` ist der unveränderte Request-Body (vor ``json.loads``). Wirft ``ValueError``,
wenn die Signatur nicht passt oder der Zeitstempel ausserhalb von ±5 Minuten liegt."""
if not header:
raise ValueError("Ratum-Signature fehlt")
timestamp: int | None = None
signatures: list[str] = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
# Nur ASCII-Ziffern (str.isdigit akzeptierte auch z. B. "²").
if key == "t" and re.fullmatch(r"[0-9]+", value):
timestamp = int(value)
elif key == "v1" and value:
# Mehrere v1-Werte sind erlaubt; einer muss passen.
signatures.append(value)
if timestamp is None or not signatures:
raise ValueError("Ratum-Signature unvollständig")
current = int(time.time()) if now is None else now
if abs(current - timestamp) > TOLERANCE_SECONDS:
raise ValueError("Zeitstempel ausserhalb des Toleranzfensters")
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
# Als Bytes vergleichen: compare_digest wirft bei Nicht-ASCII-str TypeError statt False.
if not any(
hmac.compare_digest(expected.encode(), sig.encode("utf-8", "replace")) for sig in signatures
):
raise ValueError("Signatur ungültig")Einbau in Express bzw. FastAPI (wichtig: den rohen Body verwenden):
ts
import express from 'express'
import { verifyRatumSignature } from './verify-webhook'
const app = express()
app.post('/webhooks/ratum', express.text({ type: 'application/json' }), (req, res) => {
try {
verifyRatumSignature(req.body, req.header('Ratum-Signature'), process.env.RATUM_WEBHOOK_SECRET!)
} catch {
return res.sendStatus(400)
}
const event = JSON.parse(req.body)
// event.id merken (Dedup), Arbeit in eine Queue legen …
res.sendStatus(204)
})py
import json
import os
from fastapi import FastAPI, HTTPException, Request, Response
from verify_webhook import verify_ratum_signature
app = FastAPI()
@app.post("/webhooks/ratum", status_code=204)
async def ratum_webhook(request: Request) -> Response:
body = await request.body()
try:
verify_ratum_signature(
body, request.headers.get("Ratum-Signature"), os.environ["RATUM_WEBHOOK_SECRET"]
)
except ValueError:
raise HTTPException(status_code=400)
event = json.loads(body)
# event["id"] merken (Dedup), Arbeit in eine Queue legen …
return Response(status_code=204)