Skip to content

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.

RouteZweck
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-secretneues Secret, gilt sofort für alle folgenden Zustellungen, auch Retries
POST /webhooks/{id}/testTest-Event webhook.ping senden (ignoriert Abo und Abschaltung)
GET /webhooks/{id}/deliveriesZustell-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 über plan_required, im GUI antwortet die Registrierung ohne bezahlten Tarif mit 402.
  • Nur https://-URLs, deren Host öffentlich auflöst. Private, interne, Loopback- und Link-Local-Adressen werden mit 422 webhook_url_rejected abgelehnt, bei der Registrierung und bei jeder Zustellung erneut geprüft. Redirects werden nicht verfolgt.

Ereignisse ​

EventAuslöser
document.sentDokument versendet (Status pending)
document.failedVorbereitung gescheitert (Status failed, failure_code im Payload)
signer.activatedSigner ist erstmals an der Reihe; Erinnerungen und Link-Abrufe lösen es nicht erneut aus
signer.signedSigner hat signiert
signer.declinedSigner hat abgelehnt
signer.expiredFrist des Signers abgelaufen
document.completedalle haben signiert, PDF versiegelt (Status signed)
webhook.pingnur ü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.status ist der Dokument-Status zum Zeitpunkt des Events. signer_id steht nur bei signer.*-Events, failure_code nur bei document.failed. Ein webhook.ping enthält nur data.endpoint_id.
  • id ist eindeutig pro Ereignis und steht zusätzlich im Header Ratum-Event-Id, der Typ in Ratum-Event-Type. Die Zustellung ist at-least-once: dieselbe id kann mehrfach ankommen; verarbeite sie nur einmal.
  • livemode: false kennzeichnet 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 als failed.
  • Kein Retry bei 2xx (Erfolg) und bei allen anderen Antworten (übrige 4xx, 3xx) — sie sind sofort failed.
  • 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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

v1 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:

  1. t und alle v1-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.
  2. Zeitstempel ablehnen, wenn er mehr als 5 Minuten von deiner Uhr abweicht (Replay-Schutz).
  3. 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)

ratum · Talent Factory GmbH