Skip to content
Docs Send webhooks to another service

Send webhooks to another service

Send each new or changed item to another service, sign the calls, check the signature in its code, and see each call and its tries.

Send an item to another service with a webhook

Call a webhook sends an item of your base to another service: it is an action of an automation, and a step of a button, that sends a POST to the address of the other service, its URL (Run actions when an item changes).

The body of an automation's call

With The item and its changes under What it sends, the body of a webhook call is the base's own JSON, with the header Content-Type: application/json. An automation's call, when an order's status changes:

{
  "automation": "Order shipped: tell the warehouse",
  "table": "orders",
  "op": "update",
  "at": "2026-10-18T10:29:00Z",
  "item": {"id": "…", "data": {"number": "SO-1042", "status": "shipped", "total": 120.5}},
  "changes": {"status": {"from": "paid", "to": "shipped"}}
}
  • op is create, update, delete, schedule, webhook, mail or comment, from what started the automation, and test for a call of Send a test (See each webhook call and send a test).
  • item is the item's ID and its values, in data: on a delete, as the item was. It is null for a schedule with no view.
  • changes holds, for each field, its value before (from) and after (to): on an update, the fields that changed; on a create, the fields with a value, each from null; on a delete, the fields that had a value, each to null. It is null for the other kinds. Password fields are never sent.

The body of a button's call

A button's Call a webhook step sends the button's key and the values filled in its form, if it has one, in place of the automation and its changes:

{
  "action": "ship",
  "table": "orders",
  "at": "2026-10-18T10:29:00Z",
  "item": {"id": "…", "data": {"number": "SO-1042", "status": "paid", "total": 120.5}},
  "input": {"carrier": "DHL"}
}

Send only the values you list

With JSON or Form under What it sends, the body holds only the values you list, as JSON or as a form, with the header Content-Type: application/x-www-form-urlencoded.

Limits and details

  • On the Free plan, a base sends webhooks, and takes them in, once an owner adds a payment method, with no charge. Until then, they are refused with "Add a payment method to enable this feature. This is only to prevent fraud and abuse. You will not be charged." (What the Free plan includes).

Read the two headers of a webhook call

Each webhook call of a base carries two headers of Base Captain, BaseCaptain-Event and BaseCaptain-Signature:

BaseCaptain-Event: 6f1c2a9e-1b7d-4c35-9a4e-2f3f6c1d8b10
BaseCaptain-Signature: t=1792310400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • BaseCaptain-Event is the call's ID. Each call has its own, and when the base tries a call again, the ID stays the same. Every call has it.
  • BaseCaptain-Signature shows that the call came from your base and that nobody changed it on the way. Only a call with a signing secret has it (Sign the webhook calls with a secret). t is the time of the try, in seconds since 1 January 1970 (Unix time). v1 is the HMAC-SHA256 of the text t, a dot and the body, exactly as sent, with the signing secret's value as the key, in lowercase hex. Each try gets a new t and a new v1.

Sign the webhook calls with a secret

A webhook's signing secret is a secret of your base that you also give to the service that gets the calls. The base never sends it: it uses it only to make the signature. Only admins add secrets.

  1. Make a long random value, for example with openssl rand -hex 32, and give it to the service that gets the calls.
  2. Click Settings, then Secrets, and add the secret, such as WEBHOOK_SIGNING, with that value. Under where it is sent, pick It checks calls that come in (Store the keys of other services).
  3. Open the automation, and in its Call a webhook action, pick the secret under Signing secret. Save.

From then on, each call of that action carries BaseCaptain-Signature.

Limits and details

  • Use a secret for signing alone: a secret that checks the base's own incoming calls, such as an incoming webhook's or Stripe's webhook secret, cannot sign, and saving such an action fails with "the signing secret HOOK_TOKEN checks calls that come in: sign with a secret of its own".
  • If the base does not have the secret, for example because an admin deleted it, the base does not send the call, and the call fails with "the signing secret WEBHOOK_SIGNING is not set".

Check a webhook’s signature in your code (HMAC)

The service that gets the webhook calls checks each signature, an HMAC-SHA256, before it does anything with the call:

  1. Read the body as raw bytes, before any JSON parsing. A body that is parsed and written again does not have the same bytes, and its signature does not match.
  2. Refuse a t that is more than 5 minutes from your clock. Then a call that someone recorded cannot be sent again later.
  3. Make the HMAC-SHA256 of t, a dot and the body, with the secret's value, and compare it with v1 in constant time. A plain comparison can tell an attacker how many characters match.

Check the signature in Node.js

In Node.js, with nothing to install, this service listens on port 3000 and answers 401 to a call whose signature does not match. With Express, use express.raw({ type: '*/*' }) on the webhook's route, so that req.body holds the raw bytes.

import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';

const secret = process.env.WEBHOOK_SIGNING; // the signing secret's value
const seen = new Set(); // the events you took: keep them in your database

function verify(header, body) {
  const parts = Object.fromEntries((header ?? '').split(',').map((part) => part.split('=')));
  const age = Date.now() / 1000 - Number(parts.t);
  if (!(Math.abs(age) <= 300)) return false; // also false when t is missing
  const expected = createHmac('sha256', secret).update(parts.t + '.').update(body).digest();
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

createServer((req, res) => {
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const body = Buffer.concat(chunks); // the raw bytes
    if (!verify(req.headers['basecaptain-signature'], body)) {
      res.writeHead(401).end();
      return;
    }
    const event = req.headers['basecaptain-event'];
    if (!seen.has(event)) {
      seen.add(event);
      const form = (req.headers['content-type'] ?? '').startsWith('application/x-www-form-urlencoded');
      const data = form ? Object.fromEntries(new URLSearchParams(body.toString())) : JSON.parse(body);
      console.log(data.op, data.item); // a button's step and a form have no op
    }
    res.writeHead(200).end();
  });
}).listen(3000);

Check the signature in Python

In Python, with the standard library only, the same service. With Flask, pass request.get_data() to verify; with Django, request.body.

import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs

SECRET = os.environ["WEBHOOK_SIGNING"].encode()  # the signing secret's value
seen = set()  # the events you took: keep them in your database

def verify(header, body):
    parts = dict(part.split("=", 1) for part in (header or "").split(",") if "=" in part)
    t = parts.get("t", "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), parts.get("v1", "").encode())

class Hook(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))  # the raw bytes
        if not verify(self.headers.get("BaseCaptain-Signature"), body):
            self.send_response(401)
            self.end_headers()
            return
        event = self.headers.get("BaseCaptain-Event")
        if event not in seen:
            seen.add(event)
            if self.headers.get("Content-Type", "").startswith("application/x-www-form-urlencoded"):
                data = {k: v[0] for k, v in parse_qs(body.decode(), keep_blank_values=True).items()}
            else:
                data = json.loads(body)
            print(data.get("op"), data.get("item"))  # a button's step and a form have no op
        self.send_response(200)
        self.end_headers()

HTTPServer(("", 3000), Hook).serve_forever()

Retries: a webhook call that fails is tried again

A webhook call of an automation that fails is tried again later, up to seven tries over about a day. A call is done when the service answers with a 2xx status, such as 200, within 10 seconds. When the service answers with another status, or does not answer in time, the base tries the call again. It waits longer before each try:

  1. at once, when the automation runs;
  2. 1 minute later;
  3. 5 minutes after that;
  4. 30 minutes after that;
  5. 2 hours after that;
  6. 6 hours after that;
  7. 15 hours after that, about a day after the first try.

Limits and details

  • The base keeps each call of an automation until it is done, so a restart of Base Captain loses none.
  • After the seventh try fails, the call has failed, and the base's problems, which the assistant reads, say so.
  • Some answers cannot change on a new try, so the call fails at once, with no more tries: a 3xx, a 4xx other than 408 and 429, a host that is not in the base's Allowed hosts, and a signing secret or another secret that the base does not have.
  • A base tries at most 1,000 calls an hour, tests included. Past that, the calls wait for the next hour, and the base's problems say "The base tried 1,000 webhook calls this hour, its limit: the rest wait for the next hour."
  • A button's Call a webhook step carries the same headers, but it is sent once, with no more tries and no log.

Take a webhook call that comes two times only once

The service that gets the webhook calls can get one call two times: for example, when it did the work but its answer did not come back within 10 seconds, the base tries the call again. Each try of one call has the same BaseCaptain-Event. Keep the IDs of the calls you took, and when one comes again, answer 200 and do nothing, as the code of Check a webhook’s signature in your code (HMAC) does.

Answer quickly, and do long work after you answer. A call whose answer takes more than 10 seconds counts as failed and comes again.

See each webhook call and send a test

The calls of each Call a webhook action show on the automation's page, under the action, in Calls, to the builders of the base. The newest call is first. Each call shows when it was made, whether it is done, failed or waits for its next try, and how many tries it had. Each try shows its time, the status the service answered, how long it took and its error. The body and the headers are never shown, because they can hold your customers' data. Load more shows older calls.

Send a test sends one call now and shows it at the top, marked as a test. Its body is the action's, filled in from the newest item of the automation's table; in the base's own JSON, op is test. A test is signed as the other calls are, has its own BaseCaptain-Event, and is tried only once.

Limits and details

  • The base keeps a call for 30 days after it is done or failed.
  • Send a test needs the automation to be on.

Read the calls and send a test with an API token or an AI client

The webhook calls of an automation, and its test, are also reached with an API token of a builder (Use your base from a calendar or a script):

  • GET /api/deliveries?automation=<automation id> gives the automation's calls, newest first, 50 at a time, each with its tries. When more is true, add &before=<the last call's id> for the next ones.
  • POST /api/deliveries/test with {"automation": "<id>", "action": 0} sends one test call of the automation's action at that index, from 0, and gives the call after its try.

The assistant and AI clients connected to Base Captain's MCP server do the same with the tools list_webhook_calls and test_webhook: ask "Show the calls of the automation New order: tell the warehouse". A user who is not a builder gets "Only a builder can read an automation's webhook calls".

Loading