In short: One destination per inbox, one URL, one secret. As soon as Tiro has finished processing a document, it posts all fields and line items as JSON to your address – signed, retried on trouble, and every attempt is visible in the document's history. Your system only has to offer a URL and answer with 2xx.
What webhooks are for
The table in the browser and the Excel download are enough as long as a person carries the data on. Once it should go straight into another system – the ERP, a database, a Google Sheet via an automation platform – the webhook is the shortest way: Tiro calls, your system receives, done. Typical receivers are a small endpoint in your own application, a Zapier, Make or n8n webhook, or a serverless function.
A destination belongs to exactly one inbox. You can add several destinations per inbox (say, staging and production), activate and deactivate each on its own, and each has its own secret.
The call
For every finished document Tiro sends exactly one POST to the destination's URL, Content-Type: application/json, with these headers:
| Header | Content |
|---|---|
X-Tiro-Event | Event name, today extract.completed (test calls: test) |
X-Tiro-Timestamp | Time of the call in unix seconds |
X-Tiro-Signature | v1= followed by the HMAC-SHA256 (hex) over <timestamp>.<body> with your secret |
Idempotency-Key | ID of the delivery – identical on every retry |
User-Agent | Tiro-Webhooks/1.0 |
The body is always the same envelope; the values under extract.data follow your inbox's data contract: one value per field key, the table field as an array of rows, dates as ISO 8601, numbers as numbers.
{
"event": "extract.completed",
"id": "9c1f4e0a-2b7d-4c0e-9a8b-5f6d7e8f9a0b",
"created_at": "2026-09-02T09:41:12Z",
"inbox": { "id": "3b2e…", "name": "Order confirmations" },
"document": {
"id": "7d4a…",
"filename": "OC-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": "OC-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"
}
}
The file itself is never part of the call. If you need it, fetch it through the links with your API key – the webhook stays small and access to documents stays with your key.
Verifying the signature
Anyone who knows your URL can call it. The signature proves that the call really comes from Tiro and that the content is unchanged. Verify it like this: take the raw body (bytes, not the parsed JSON), build <timestamp>.<body>, compute the HMAC-SHA256 with your secret and compare it in constant time with the value after v1=. Discard calls whose timestamp is more than five minutes off your clock – that defuses replay attacks.
Python (Flask):
import hmac, hashlib, time
from flask import Flask, request, abort
SECRET = "whsec_…" # from the app, shown once
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() # raw 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"]
# … write to ERP, database or sheet
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;
// … process further
}
res.sendStatus(204);
});
Important in both cases: verify the raw body before a framework parses and re-serializes it – otherwise the signature never matches.
Retries and idempotency
Answer quickly with a 2xx status and do slow work afterwards; Tiro waits at most ten seconds. If Tiro gets 5xx, 408, 429 or no answer, it repeats the call after 1, 5 and 15 minutes, then after 1, 3, 6 and 12 hours – eight attempts over roughly a day. Because your server may have processed a call without the answer arriving, Tiro sends the same Idempotency-Key on every retry. Remember processed keys and skip duplicates.
Answers such as 400, 401, 404 or redirects (3xx) count as a rejection: Tiro does not retry but marks the delivery as failed in the document's history. After fixing your receiver you push it again there with one click. When a document is reprocessed, your destination receives the new result as a delivery of its own.
Security
- https only, public only: Tiro resolves the hostname before every call and refuses private, local and metadata addresses. The connection goes to the checked address; the hostname is used only for certificate verification.
- Secret visible once: It is stored encrypted and never shown again. “Rotate secret” creates a new one; the old one stops working immediately.
- No file in the call: The payload carries metadata and values, never the document's content. Downloads go through the API with your key.
- No redirects: A 3xx is not followed – your URL must be the final one.
Via the API
Everything the “Destinations” tab does also works over REST: POST /api/v1/destinations adds a destination (the response contains the secret once), PATCH changes URL or active state, POST …/rotate-secret and POST …/test match the buttons. If you prefer polling to being called, use GET /api/v1/extracts?since=<ISO time> – newest results first, with next_cursor for paging. Details are in the API documentation inside the app.