Integration

Webhooks: Send extracted data to your system automatically

Set up webhooks in Tiro: add a destination, keep the secret, payload and signature check in Python or Node – every finished document lands in your system.

By Christian Gerloff · Published September 2, 2026

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:

HeaderContent
X-Tiro-EventEvent name, today extract.completed (test calls: test)
X-Tiro-TimestampTime of the call in unix seconds
X-Tiro-Signaturev1= followed by the HMAC-SHA256 (hex) over <timestamp>.<body> with your secret
Idempotency-KeyID of the delivery – identical on every retry
User-AgentTiro-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.

How it works

  1. Add a destination

    Open the inbox in the app, switch to the “Destinations” tab and enter the https URL of your receiver. Tiro checks the address and shows the signing secret exactly once – copy it straight into your system's configuration.

  2. Send a test event

    Click “Send test event”. Tiro posts a call with sample data to your URL and shows the HTTP status, the duration and, if any, the error text. If your server answers with 2xx, you are ready.

  3. Process a document and check the delivery

    Upload a document or forward it by e-mail. As soon as the result is ready, Tiro calls your URL; the document's history shows every delivery attempt with status and HTTP code – and lets you retry a failed delivery with one click.

Frequently asked questions

What is a webhook, in short?

A webhook is an HTTP call Tiro makes to an address of your choice as soon as something happened – here, whenever a document has been processed. Instead of asking regularly, your system gets the data delivered.

What does a webhook cost?

Nothing. Deliveries use no credits – only the processing of the document counts, as everywhere in Tiro.

How often does Tiro retry when my server does not answer?

If your server answers with 5xx, 408 or 429, or not at all, Tiro repeats the call with growing pauses – after 1, 5 and 15 minutes, then after 1, 3, 6 and 12 hours, eight attempts over roughly a day. After that the delivery shows as failed in the document's history and you can push it again by hand. Other answers such as 400 or 404 count as a rejection right away.

Why does Tiro refuse my URL?

Only https addresses of publicly reachable servers are allowed. Tiro refuses addresses on private networks, on localhost or of cloud metadata services, as well as URLs with embedded credentials. For local testing use a tunnel such as ngrok or Cloudflare Tunnel.

What if I lose the secret?

Tiro stores the secret encrypted and cannot show it again. Click “Rotate secret” on the destination – you get a new secret, the old one stops working immediately, and you enter the new one in your system.

Do I also hear about documents that could not be processed?

Yes. Instead of finished results, a destination can subscribe to documents that failed for good – create it through the REST API with event document.failed. The call then carries status, error message and links to the document, but no extract. In the app you keep creating destinations for results.

Is there Zapier, Make or n8n?

Not yet as ready-made apps. Both platforms already work with Tiro today – through “Webhooks by Zapier” or the HTTP trigger in Make and n8n, which accept a webhook, or by polling the REST endpoint GET /extracts?since=. Listed apps in their directories are planned.