Webhooks

What a webhook does

A webhook pushes results to you instead of making you ask for them. Set an HTTPS endpoint on a flow, and every time a run completes, Tavnit sends that run's extracted rows to your URL as a JSON POST — usually within a second of the run finishing.

The alternative is polling: calling the API on a timer to ask whether anything finished. Polling costs you requests, adds latency, and gets worse as volume grows. A webhook arrives once, when there is something to deliver.

Use a webhook whenUse something else when
You want results in your own system the moment they existA person needs to read them — email output is better
You are wiring Tavnit into Make, Zapier, n8n or Power AutomateYou want the data queryable inside Tavnit — use a Bucket
Volume is high enough that polling is wastefulYou are fetching a specific known run — call the API directly

Set up a webhook

Webhooks are configured per flow. You need an HTTPS endpoint that accepts a POST with a JSON body — Tavnit rejects plain http:// URLs, because the payload contains your extracted document data.

  1. 1Get an endpoint URL. Automation platforms hand you one when you create a webhook trigger; otherwise expose your own HTTPS route.
  2. 2Open the flow in Flows.
  3. 3Find the Webhook panel, paste the URL and save.
  4. 4Process one test document and confirm the POST arrived. The run's log records whether delivery succeeded and what status code came back.
A Tavnit flow detail page for Invoice Processor, showing the left rail with Email Trigger, Collections, Cleaner, Agent, Form Templates, Email Output, Webhook, Bucket Export and Human in the Loop, next to the flow's metadata and table fields.
Webhook sits with the other output options in a flow's left rail, alongside Email Output and Bucket Export.
Cleaners have their own webhook

A Cleaner can POST its swept results independently of the flow, also HTTPS only. Use the flow webhook for per-document results; use the Cleaner webhook when you want the cleaned dataset after each sweep.

What the payload looks like

The body is the run's output plus its identifiers. Repeating line items arrive under rows, single-value fields under metadata, and run_id and flow_id tell you which run produced them.

JSON — flow webhook body
{
  "run_id": "8f1c2b7e-4d3a-4a91-9c11-2f7b6e0d5a44",
  "flow_id": "3a9d51c0-77b2-4e18-9f6d-0c4a1b8e2d63",
  "rows": [
    {
      "Description": "Software Platform Subscription — January",
      "Quantity": 1,
      "Price": 180.00,
      "Amount": 180.00,
      "Invoice Number": "001",
      "Issued Date": "2026-01-15",
      "Total": 270.30
    }
  ],
  "metadata": {
    "Invoice Number": "001",
    "Billed To": "Acme Ltd",
    "Total": 270.30
  }
}

The field names inside rows and metadata are the ones you defined on the flow, so the payload changes shape when you change the schema. If a Cleaner is attached, what you receive is the cleaned output — converted currencies, computed columns and all.

KeyAlways presentWhat it is
run_idYesThe run that produced this result.
flow_idYesThe flow that processed the document.
rowsYesOne entry per extracted line item. An empty array is valid — some documents have no table.
metadataYesSingle-value fields that describe the document as a whole.
collection_run_idNoPresent when a Collection routed the document to this flow.
split_id, splitter_doc_titleNoPresent when a Splitter produced this segment.
JSON — provenance keys
{
  "run_id": "...",
  "flow_id": "...",

  // present when a Collection routed the document
  "collection_run_id": "b21e9f34-8c55-4d70-a6e2-91f0c7d43a18",

  // present when a Splitter produced this segment
  "split_id": "c74a0b12-3e69-4f85-b0d7-58e2a9c61f70",
  "splitter_doc_title": "Commercial Invoice",

  "rows": [],
  "metadata": {}
}
Files arrive as links, not bytes

Fields holding a file or an image are not embedded in the JSON. They come through as time-limited URLs, because stored documents are private — a raw storage path would not be fetchable from your server. Download them promptly rather than storing the link.

Delivery, timeouts and retries

Tavnit waits up to 10 seconds for your endpoint to respond. A connection failure or timeout is retried once after a short pause; an HTTP error response is not retried, because your server was reached and answered.

What your endpoint doesWhat Tavnit does
Responds 2xx within 10 secondsDelivery is recorded as sent. Done.
Connection refused, dropped, or times outRetried once after a short pause. If the retry also fails, delivery is marked failed.
Responds 4xx or 5xxNot retried. The status code is recorded on the run so you can see what your server said.

There is no long retry queue and no dead-letter replay. If your endpoint is down for an hour, those deliveries are lost — the runs still succeeded and their data is still in Tavnit, but you will have to fetch it over the API or re-deliver it another way. For anything you cannot afford to miss, pair the webhook with a Bucket so there is always a durable copy.

A failed webhook never fails the run

Delivery is best-effort and separate from processing. If your endpoint is unreachable, the run still completes, the data is still stored, and every other output — email, Bucket export, form fill — still fires.

Writing a receiver

The single most important rule: acknowledge fast, then work. Ten seconds sounds generous until your handler writes to a slow database. Return 200 as soon as you have the payload safely queued, and do the real processing afterwards.

Python (Flask)
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.post("/tavnit-webhook")
def receive():
    payload = request.get_json(silent=True) or {}

    run_id = payload.get("run_id")
    rows = payload.get("rows", [])

    # Acknowledge immediately. Tavnit waits 10 seconds for a response and
    # treats a timeout as a failed delivery, so queue the slow work instead
    # of doing it inline.
    enqueue_processing(run_id, rows)

    return jsonify({"received": True}), 200
JavaScript (Express)
import express from "express";

const app = express();
app.use(express.json({ limit: "10mb" }));

app.post("/tavnit-webhook", (req, res) => {
  const { run_id: runId, rows = [] } = req.body ?? {};

  // Respond inside the 10-second window, then do the work.
  res.status(200).json({ received: true });

  enqueueProcessing(runId, rows).catch(console.error);
});

app.listen(3000);
  • Accept a reasonably large body — a long invoice with many line items is not small.
  • Treat delivery as at-least-once. A retry after a timeout can deliver the same run twice, so make your handler idempotent by keying on run_id.
  • Do not assume a fixed schema. Read fields by name and tolerate ones you do not recognise, so adding a flow field does not break your receiver.
  • Log the raw body on failure. It is the only copy of what arrived.
Keep the URL secret

The endpoint URL is the only thing standing between the internet and your extracted data. Automation platforms embed a secret token in the path for exactly this reason. Do not publish it, and rotate it if it leaks.

Webhooks from Cleaner rules and agents

Flows are not the only thing that can call you. A Cleaner conditional action can fire a webhook when a row breaks a rule, and an agent can deliver its captured output to one. These are separate configurations with separate payloads.

SourceFires whenCarries
Flow webhookA run completes successfullyThe run's rows and metadata
Cleaner webhookA sweep finishesThe cleaned dataset
Cleaner conditional actionA row matches your rule — once per rule per run, not once per rowA notification you compose, with the matching rows
Agent deliveryAn agent run finishesThe agent, the run, its status and the captured output

A rule-triggered notification is dispatched as soon as the rule matches — before any review pause. That is deliberate: alert me when this happens should not wait on a reviewer. The flow webhook, by contrast, only fires after a reviewer approves.

Troubleshooting

Start at the run, not at your server. Every run logs whether webhook delivery was attempted, whether it succeeded, and what status code or error came back — which immediately tells you whether the problem is Tavnit-side or yours.

SymptomLikely causeFix
Nothing arrives, no attempt loggedNo webhook URL is set on that flow, or the run failed before delivery.Check the flow's Webhook panel and the run's status.
The URL was rejected when savingIt does not start with https://.Use an HTTPS endpoint. Plain HTTP is not accepted.
Delivery logged as failed with a status codeYour endpoint returned 4xx or 5xx. It was reached, so there was no retry.Read your own server's logs — the payload is usually fine and the handler threw.
Delivery logged as failed with a timeoutYour handler took longer than 10 seconds.Acknowledge first and process asynchronously, as above.
The same run arrived twiceA retry followed a timeout on a request your server actually processed.De-duplicate on run_id.
Results arrive much later than expectedThe flow has human review enabled.The webhook fires on approval, not on extraction. That is by design.