Webhooks
A webhook sends a signed POST to your server when a run of a workflow ends or needs a person, so you don't have to poll. Add one on the workflow's Settings page, or with POST /api/v1/workflows/:id/webhooks. The signing secret (whsec_…) is shown once, when you add the webhook or rotate its secret.
Events
execution.succeeded,execution.failed,execution.cancelled: the run ended. Sent once per run, after its result is written.execution.needs_human: the run paused on something only a person can do (a login, a CAPTCHA, a one-time code).data.humanAttentionsays what, with a link to the live session.webhook.test: sent when you press Send test (or callPOST /api/v1/webhooks/:id/test).
Only events after the webhook was added are sent, and only while it is on.
Payload
A JSON body. data.result is dropped (and resultTruncated set) when the body would be larger than 256 KB; store.changes lists at most 1,000 rows.
{
"id": "whd_0b7c…",
"type": "execution.succeeded",
"createdAt": "2026-10-18T09:00:12.000Z",
"data": {
"workflow": {
"id": "wf_…",
"name": "Daily prices"
},
"execution": {
"id": "exec_…",
"status": "succeeded",
"startedAt": "2026-10-18T09:00:00.000Z",
"endedAt": "2026-10-18T09:00:11.000Z",
"url": "https://www.tabductor.com/app/workflows/wf_…/runs",
"apiUrl": "https://www.tabductor.com/api/v1/executions/exec_…"
},
"trigger": {
"type": "schedule"
},
"summary": "Collected 12 prices; 2 changed.",
"result": {
"prices": [
{
"sku": "A-1",
"price": 12.5
}
]
},
"resultTruncated": false,
"store": {
"changes": [
{
"table": "prices",
"key": {
"sku": "A-1"
},
"op": "write"
}
],
"truncated": false
},
"files": [
{
"id": "file_…",
"name": "report.pdf",
"mime": "application/pdf",
"bytes": 48213,
"sha256": "…",
"source": "run",
"workflowId": "wf_…",
"executionId": "exec_…",
"createdAt": "2026-10-18T09:00:10.000Z",
"deleted": false,
"apiUrl": "https://www.tabductor.com/api/v1/files/file_…"
}
]
}
}Verifying webhook signatures
Deliveries follow Standard Webhooks, so its libraries verify them too. Each request carries:
webhook-id: the delivery id, the same on every retry. Use it to ignore duplicates.webhook-timestamp: Unix seconds when this attempt was signed. Reject old ones.webhook-signature:v1,<base64 signature>, the HMAC-SHA256 of<id>.<timestamp>.<body>keyed with the base64-decoded part of your secret afterwhsec_. For 24 hours after a rotation there are two, separated by a space.
import { createHmac, timingSafeEqual } from "node:crypto";
/** True when the delivery was signed with your webhook's secret (whsec_…) in the last 5 minutes. */
export function verifyTabductorWebhook(secret, headers, body) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 5 * 60) return false; // stops replays
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
// Several "v1,<signature>" entries, space separated, while a secret is being rotated.
return signatures.split(" ").some((entry) => {
const [version, value = ""] = entry.split(",");
const given = Buffer.from(value, "base64");
return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
});
}import express from "express";
const app = express();
// Verify the exact bytes that were signed: read the raw body, not re-serialized JSON.
app.post("/hooks/tabductor", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body.toString("utf8");
if (!verifyTabductorWebhook(process.env.TABDUCTOR_WEBHOOK_SECRET, req.headers, body)) return res.status(400).end();
res.status(204).end(); // answer within 10 seconds; do slow work afterwards
const event = JSON.parse(body);
// webhook-id is the same on every retry of one delivery: skip ids you have already handled.
console.log(event.type, event.data.execution?.status, event.data.summary);
});
app.listen(3000);Retries
Any 2xx answer counts as delivered. A timeout (10 seconds), a network error, 408, 409, 425, 429 or 5xx is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours and 8 hours (8 attempts over about a day). Redirects aren't followed, and other 4xx answers aren't retried: change the URL or fix the endpoint, then send a test. The Settings page lists each delivery for 30 days with what happened.
Allowed URLs
Webhook URLs must use https://, the default port, 443 or a port from 1024 up, and no user name or password. Every address the host resolves to must be a public internet address: private, loopback, link-local and other internal addresses are refused when each delivery is sent.
See API & MCP for API keys; the REST endpoints are in https://www.tabductor.com/api/v1/openapi.json under Webhooks.