Tabductor
Developer docs

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.humanAttention says what, with a link to the live session.
  • webhook.test: sent when you press Send test (or call POST /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 after whsec_. 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.

Webhooks · Tabductor docs