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.
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"}}
} opiscreate,update,delete,schedule,webhook,mailorcomment, from what started the automation, andtestfor a call of Send a test (See each webhook call and send a test).itemis the item's ID and its values, indata: on a delete, as the item was. It isnullfor a schedule with no view.changesholds, 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 fromnull; on a delete, the fields that had a value, each tonull. It isnullfor 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).
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-Eventis 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-Signatureshows 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).tis the time of the try, in seconds since 1 January 1970 (Unix time).v1is the HMAC-SHA256 of the textt, a dot and the body, exactly as sent, with the signing secret's value as the key, in lowercase hex. Each try gets a newtand a newv1.
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.
- Make a long random value, for example with
openssl rand -hex 32, and give it to the service that gets the calls. - 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).
- 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".
The service that gets the webhook calls checks each signature, an HMAC-SHA256, before it does anything with the call:
- 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.
- Refuse a
tthat is more than 5 minutes from your clock. Then a call that someone recorded cannot be sent again later. - Make the HMAC-SHA256 of
t, a dot and the body, with the secret's value, and compare it withv1in 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()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:
- at once, when the automation runs;
- 1 minute later;
- 5 minutes after that;
- 30 minutes after that;
- 2 hours after that;
- 6 hours after that;
- 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.
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.
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.
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. Whenmoreis true, add&before=<the last call's id>for the next ones.POST /api/deliveries/testwith{"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".