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 seconds 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 when | Use something else when |
|---|---|
| You want results in your own system the moment they exist | A person needs to read them: email output is better |
| You are wiring Tavnit into Make, Zapier, n8n or Power Automate | You want the data queryable inside Tavnit: use a Bucket |
| Volume is high enough that polling is wasteful | You are fetching a specific known run: call the API directly |
Which features can send webhooks
Flows are the most common source, but most features that produce a result can POST it to a URL. Each one is configured on its own detail page and sends its own payload.
| Source | Where you set it | Fires when |
|---|---|---|
| Flow | Webhook panel | A run completes successfully (after approval, if review is on) |
| Cleaner | Webhook panel | A sweep the Cleaner runs on its own finishes (Clean Dataset and API sweeps). Flow runs deliver through the flow's webhook instead. |
| Cleaner conditional action | A webhook action in Conditional Actions | A rule matches: once per rule per run, not once per row |
| Agent | Delivery, Webhook option | An agent run completes |
| Matcher | Webhook panel | A match completes |
| Inspector | Webhook panel, and Call webhook under If this check fails | An inspection finishes; the per-check action fires for each failed check |
| Filler | Webhook panel | A fill completes |
| Signal | Webhook panel | A Wave finishes |
| Net | Webhook panel | A Catch finishes with at least one row |
| Pipeline | An Output node set to Webhook, Zapier, Make, n8n, Slack, Teams or Google Chat | The execution reaches the Output node |
Payloads contain your document data, so use an https:// endpoint. The Webhook panels refuse to save a plain http:// URL.
Set up a flow webhook
Flow webhooks are configured per flow. You need an HTTPS endpoint that accepts a POST with a JSON body.
- 1Get an endpoint URL. Automation platforms hand you one when you create a webhook trigger; otherwise expose your own HTTPS route.
- 2Open the flow in Flows.
- 3Open the Webhook panel, paste the URL and save.
- 4Process one test document and confirm the POST arrived. The run's log records whether delivery succeeded and what status code came back.

What a flow 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.
{
"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. If the Cleaner has a pivot applied to the webhook payload, the rows arrive in the pivoted, wide format.
| Key | Always present | What it is |
|---|---|---|
run_id | Yes | The run that produced this result. |
flow_id | Yes | The flow that processed the document. |
rows | Yes | One entry per extracted line item. An empty array is valid: some documents have no table. |
metadata | Yes | Single-value fields that describe the document as a whole. |
collection_run_id | No | Present when a Collection routed the document to this flow. |
split_id, splitter_doc_title | No | Present when a Splitter produced this segment. |
{
"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": {}
}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.
Payloads from other sources
Every source sends JSON, but the keys differ. Most carry the source's result plus the ids you need to look it up in Tavnit.
| Source | Keys in the body |
|---|---|
| Cleaner sweep | The cleaned output, plus sweep_id, cleaner_id, and run_id when an API sweep re-cleans a run. |
| Cleaner conditional action | content (the message you composed, which can include the matching rows), receivers, run_id, flow_id, flow_name, sweep_id and matched_rows (how many rows matched). |
| Agent | bot_id, bot_run_id, status, inputs, output, and flow_run_id when a flow run triggered the agent. |
| Matcher | The match result, plus match_id and matcher_id. |
| Inspector | inspection_id, inspector_id, verdict and the full report in output_json. A per-check webhook sends the failed check in item instead of the report. |
| Filler | fill_id, filler_id, status, filled_forms (plus filled_form_path for the first form) and the field values in output_json. |
| Signal | The Wave's result, plus wave_id and signal_id. |
| Net | columns, rows, catch_id, net_id, window_start, window_end and a stats summary. |
| Pipeline Output | pipeline_id, execution_id, pipeline_name, original_filename and outputs, one entry per upstream node. |
{
"bot_id": "...",
"bot_run_id": "...",
"status": "completed",
"flow_run_id": "...", // only when a flow run triggered the agent
"inputs": { "supplier_name": "Acme Ltd" },
"output": { ... }
}An agent webhook always includes the agent's input variables (secrets are never included) so your receiver can join the captured output back onto its own records. The flow_run_id tells you which document the agent was working from when a flow run started it.
A conditional action 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.
Pipeline Output to Slack, Teams and Google Chat
In a Pipeline, an Output node sends the results of the nodes feeding it to a destination you pick: Email, Slack, Teams, Google Chat, Webhook, Zapier, Make or n8n.
- Slack, Teams and Google Chat receive a readable message with the key fields, posted through the incoming-webhook URL you paste from that app.
- Webhook, Zapier, Make and n8n receive the raw JSON shown below.
- If a flow in the pipeline already sends its own webhook, the pipeline warns you that an Output would post the same result twice.
{
"pipeline_id": "...",
"execution_id": "...",
"pipeline_name": "Supplier intake",
"original_filename": "bundle.pdf",
"outputs": [
{ "node": "Invoice Processor", "node_type": "flow", "output": { ... } }
]
}Delivery, timeouts and retries
Every source uses the same delivery rules. 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 does | What Tavnit does |
|---|---|
| Responds 2xx within 10 seconds | Delivery is recorded as sent. Done. |
| Connection refused, dropped, or times out | Retried once after a short pause. If the retry also fails, delivery is marked failed. |
| Responds 4xx or 5xx | Not retried. The status code is recorded 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.
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. The exception is a pipeline Output node: a failed delivery marks that node as failed so you can see it on the execution.
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.
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}), 200import 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 result twice, so make your handler idempotent by keying on run_id (or the source's own 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.
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.
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.
| Symptom | Likely cause | Fix |
|---|---|---|
| Nothing arrives, no attempt logged | No 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 saving | It does not start with https://. | Use an HTTPS endpoint. Plain HTTP is not accepted. |
| The Cleaner webhook never fires for flow runs | Cleaner webhooks only fire for sweeps the Cleaner runs on its own. | Use the flow's Webhook panel; it already carries the cleaned output. |
| Delivery logged as failed with a status code | Your 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 timeout | Your handler took longer than 10 seconds. | Acknowledge first and process asynchronously, as above. |
| The same run arrived twice | A retry followed a timeout on a request your server actually processed. | De-duplicate on run_id. |
| Results arrive much later than expected | The flow has human review enabled. | The webhook fires on approval, not on extraction. That is by design. |
