Kurz gesagt: Ein Ziel je Inbox, eine URL, ein Secret. Sobald Tiro ein Dokument fertig verarbeitet hat, schickt es einen POST mit allen Feldern und Positionen als JSON an deine Adresse – signiert, mit Wiederholungen bei Störungen, und jeder Versuch ist im Verlauf des Dokuments sichtbar. Dein System muss nur eine URL anbieten und mit 2xx antworten.
Wofür Webhooks gedacht sind
Die Tabelle im Browser und der Excel-Download reichen, solange ein Mensch die Daten weiterträgt. Sobald sie ohne Umweg in ein anderes System sollen – ins ERP, in eine Datenbank, in ein Google Sheet über eine Automatisierungsplattform –, ist der Webhook der kürzeste Weg: Tiro meldet sich, dein System nimmt entgegen, fertig. Typische Empfänger sind ein kleiner Endpunkt in deiner eigenen Anwendung, ein Zapier-, Make- oder n8n-Webhook oder eine Serverless-Funktion.
Ein Ziel gehört zu genau einer Inbox. Du kannst mehrere Ziele je Inbox anlegen (etwa Test- und Produktivsystem), jedes einzeln aktivieren und deaktivieren, und jedes hat sein eigenes Secret.
Der Aufruf
Für jedes fertige Dokument sendet Tiro genau einen POST an die URL des Ziels, Content-Type: application/json, mit diesen Headern:
| Header | Inhalt |
|---|---|
X-Tiro-Event | Ereignisname, heute extract.completed (Test-Aufrufe: test) |
X-Tiro-Timestamp | Zeitpunkt des Aufrufs in Unix-Sekunden |
X-Tiro-Signature | v1= gefolgt vom HMAC-SHA256 (hex) über <timestamp>.<body> mit deinem Secret |
Idempotency-Key | ID der Zustellung – bei jedem Wiederholungsversuch identisch |
User-Agent | Tiro-Webhooks/1.0 |
Der Body ist immer derselbe Umschlag; die Werte unter extract.data folgen dem Datenvertrag deiner Inbox: ein Wert je Feld-Key, das Tabellen-Feld als Array von Zeilen, Datum als ISO 8601, Zahlen als Zahlen.
{
"event": "extract.completed",
"id": "9c1f4e0a-2b7d-4c0e-9a8b-5f6d7e8f9a0b",
"created_at": "2026-09-02T09:41:12Z",
"inbox": { "id": "3b2e…", "name": "Auftragsbestätigungen" },
"document": {
"id": "7d4a…",
"filename": "AB-2026-0815.pdf",
"kind": "pdf",
"content_type": "application/pdf",
"pages": 2,
"credits": 2,
"source": "email",
"status": "done",
"created_at": "2026-09-02T09:40:58Z"
},
"extract": {
"data": {
"order_number": "AB-2026-0815",
"order_date": "2026-09-01",
"supplier": "Example Supplies Ltd.",
"total": 1234.5,
"line_items": [
{ "description": "Steel bracket, 40 mm", "quantity": 12, "unit_price": 8.9, "amount": 106.8 },
{ "description": "Assembly", "quantity": 1, "unit_price": 1127.7, "amount": 1127.7 }
]
},
"created_at": "2026-09-02T09:41:11Z"
},
"links": {
"document": "/api/v1/inboxes/3b2e…/documents/7d4a…",
"extract": "/api/v1/inboxes/3b2e…/documents/7d4a…/extract",
"download_csv": "/api/v1/inboxes/3b2e…/documents/7d4a…/download?format=csv",
"download_xlsx": "/api/v1/inboxes/3b2e…/documents/7d4a…/download?format=xlsx"
}
}
Die Datei selbst ist nie Teil des Aufrufs. Brauchst du sie, holst du sie über die Links mit deinem API-Schlüssel – so bleibt der Webhook klein und der Zugriff auf Dokumente bleibt bei deinem Schlüssel.
Signatur prüfen
Wer deine URL kennt, kann sie aufrufen. Die Signatur stellt sicher, dass der Aufruf wirklich von Tiro kommt und der Inhalt unverändert ist. Prüfe sie so: Nimm den rohen Body (Bytes, nicht das geparste JSON), setze <timestamp>.<body> zusammen, bilde den HMAC-SHA256 mit deinem Secret und vergleiche ihn in konstanter Zeit mit dem Wert hinter v1=. Verwirf Aufrufe, deren Zeitstempel mehr als fünf Minuten von deiner Uhr abweicht – das entschärft Wiederholungsangriffe.
Python (Flask):
import hmac, hashlib, time
from flask import Flask, request, abort
SECRET = "whsec_…" # aus der App, einmalig angezeigt
app = Flask(__name__)
@app.post("/webhooks/tiro")
def tiro_webhook():
timestamp = request.headers.get("X-Tiro-Timestamp", "")
signature = request.headers.get("X-Tiro-Signature", "")
body = request.get_data() # rohe Bytes
if abs(time.time() - int(timestamp or 0)) > 300:
abort(400)
expected = hmac.new(SECRET.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(f"v1={expected}", signature):
abort(401)
event = request.get_json()
if event["event"] == "extract.completed":
data = event["extract"]["data"]
# … in ERP, Datenbank oder Tabelle schreiben
return "", 204
Node (Express):
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.TIRO_WEBHOOK_SECRET; // whsec_…
const app = express();
app.post("/webhooks/tiro", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Tiro-Timestamp") ?? "";
const signature = req.get("X-Tiro-Signature") ?? "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);
const expected = "v1=" + createHmac("sha256", SECRET).update(`${timestamp}.`).update(req.body).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(signature);
if (a.length !== b.length || !timingSafeEqual(a, b)) return res.sendStatus(401);
const event = JSON.parse(req.body);
if (event.event === "extract.completed") {
const data = event.extract.data;
// … weiterverarbeiten
}
res.sendStatus(204);
});
Wichtig in beiden Fällen: Der Body muss roh geprüft werden, bevor ein Framework ihn parst und neu serialisiert – sonst stimmt die Signatur nie.
Wiederholungen und Idempotenz
Antworte schnell mit einem 2xx-Status und erledige langsame Arbeit danach; Tiro wartet höchstens zehn Sekunden. Bekommt Tiro 5xx, 408, 429 oder keine Antwort, wiederholt es den Aufruf nach 1, 5 und 15 Minuten, dann nach 1, 3, 6 und 12 Stunden – acht Versuche über rund einen Tag. Weil dein Server zwischendurch einen Aufruf verarbeitet haben kann, ohne dass die Antwort ankam, schickt Tiro bei jeder Wiederholung denselben Idempotency-Key. Merke dir verarbeitete Keys und überspringe Dubletten.
Antworten wie 400, 401, 404 oder Weiterleitungen (3xx) gelten als Ablehnung: Tiro wiederholt nicht, sondern markiert die Zustellung im Verlauf des Dokuments als fehlgeschlagen. Nach einer Korrektur an deinem Empfänger stößt du sie dort mit einem Klick erneut an. Wird ein Dokument neu verarbeitet, bekommt dein Ziel das neue Ergebnis als eigene Zustellung.
Sicherheit
- Nur https, nur öffentlich: Tiro löst den Hostnamen vor jedem Aufruf auf und weist private, lokale und Metadaten-Adressen ab. Verbunden wird mit der geprüften Adresse; der Hostname dient nur noch der Zertifikatsprüfung.
- Secret nur einmal sichtbar: Es wird verschlüsselt gespeichert und nie erneut angezeigt. „Secret rotieren“ erzeugt ein neues; das alte gilt sofort nicht mehr.
- Keine Datei im Aufruf: Der Payload enthält Metadaten und Werte, nie den Dokumentinhalt. Downloads laufen über die API mit deinem Schlüssel.
- Keine Weiterleitungen: Ein 3xx wird nicht gefolgt – deine URL muss die endgültige sein.
Über die API
Alles, was der Reiter „Ziele“ kann, geht auch per REST: POST /api/v1/destinations legt ein Ziel an (Antwort enthält das Secret einmalig), PATCH ändert URL oder Aktiv-Status, POST …/rotate-secret und POST …/test entsprechen den Schaltflächen. Wer lieber abfragt als angerufen zu werden, nutzt GET /api/v1/extracts?since=<ISO-Zeit> – neueste Ergebnisse zuerst, mit next_cursor zum Weiterblättern. Die Details stehen in der API-Dokumentation in der App.