Webhooks
A webhook tells your own service when something happens on a project: a deployment goes live or fails, a domain is verified, a variable changes. Bastide posts a signed event to the address you declare, retries on failure, and keeps a log of deliveries. This page explains how to declare an endpoint, which events exist, and how to verify their signature.
Concepts
- An endpoint is an
https://address of your service, declared on a project, with the list of event types it receives (all of them if the list is empty). Its identifier starts withwhk_. - An event is a fact about the project. Its identifier (
evt_…) never changes from one send to the next. - A delivery (
lvr_…) is the sending of one event to one endpoint. It is pending (pending), delivered (delivered) or dead-lettered (dead). - Delivery is at least once: the same event can arrive twice. Deduplicate on its
id.
Declaring an endpoint
You need the Administrateur (Administrator) rank on the project.
- Open the project, then Webhooks.
- In "Déclarer un point de réception" (declare an endpoint), enter the address and, if you wish, the event types to receive.
- Déclarer le point (declare the endpoint).
- Copy the secret shown (
whsec_…) and store it in your service: it will not be shown again.
Through the API, the operation is webhook.endpoint.create:
{
"url": "https://example.com/bastide/events",
"types": ["deployment.ready", "deployment.failed"]
}
The Webhooks screen then shows each endpoint, its deliveries and their state. From an endpoint's card, Faire tourner le secret (rotate the secret) mints a new one, and Supprimer (delete) removes the endpoint along with its pending deliveries.
Note. Declaring or deleting an endpoint, and rotating its secret, have no MCP tool: an agent does not create outbound flows and does not receive secrets. An agent can read deliveries (
webhook_list) and replay one (webhook_replay_create).
The envelope
Each send is a JSON POST:
{
"id": "evt_01JQ8ZK4M7NP2RSTVWXYZ34567",
"type": "deployment.ready",
"createdAt": "2026-10-28T09:14:02.120Z",
"attempt": 1,
"tenantId": "org_01JQ8ZK4M7NP2RSTVWXYZ34567",
"projectId": "prj_01JQ8ZK4M7NP2RSTVWXYZ34567",
"payload": { }
}
attemptis 1 on the first send and keeps counting from one send to the next, replays included.- The headers
Bastide-Event-Id,Bastide-Event-TypeandBastide-Attemptrepeat these values, next toBastide-Signature. payload.actorsays who made the move:accountIdfor a person,serviceTokenIdfor a service account,channelfor the route (console,api,cli,mcp, orforgeandhookfor a hook). WhenagentSessionis not null, an agent acted, andaccountIdis the person who mandated it.actoris null when no call is behind the fact, for example a deployment becoming healthy.- A variable's value is never sent.
Events
| Type | When |
|---|---|
deployment.created | A deployment is created and waits for its build. |
deployment.built | The build produced a signed image. |
deployment.ready | The deployment is serving traffic. |
deployment.failed | The deployment failed; the cause is read with deployment.read. |
deployment.canceled | The deployment was cancelled before serving. |
deployment.restored | An earlier deployment is put back into service. |
project.created | A project is created. |
project.renamed | A project changes name. |
project.deleted | A project is deleted, at the end of its grace period. |
project.anomaly.detected | An environment enters an anomaly (error rate, latency, saturation, crash loop). |
project.egress.threshold | Outbound traffic nears the tier's sustained cap. |
project.images.threshold | Image transformations pass 80 % of the tier's monthly cap. |
domain.added | A domain is added. |
domain.verified | Ownership of a domain is verified. |
domain.misconfigured | The domain no longer points to the platform; it will be removed in seven days unless it points there again. |
domain.deleted | A domain stops being served. |
variable.created, variable.updated, variable.deleted | An environment variable is created, changes value or is deleted. |
database.branch.created, database.branch.destroyed | A database branch is created or destroyed. |
database.cron.failed | A scheduled job in the database failed, or did not start within its window. |
database.schema.drifted | The real database drifts from the schema declared by the repository's migrations. |
Other types are already in the catalogue but not emitted yet: deployment promotion, deleting a single deployment, certificates, backups, spending thresholds and member changes. You can tick them; they will arrive once the feature is served. The full catalogue is in the reference page of webhook.endpoint.create.
Verifying the signature
Each send carries:
Bastide-Signature: t=<Unix seconds>,v1=<hex of HMAC-SHA256(secret, "<t>.<body>")>
- Recompute
HMAC-SHA256(secret, t + "." + body)over the raw body, before any JSON parser. The key is the whole secret,whsec_prefix included. - Accept if any
v1value matches, using a constant-time comparison. - Reject a
tmore than 300 seconds away from your clock: that is the replay protection.
After a rotation, the old secret keeps signing for 24 hours: the header then carries two v1 values, and your service can switch to the new secret without losing anything.
A Node example, with no dependency:
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.BASTIDE_WEBHOOK_SECRET; // whsec_…
const WINDOW_S = 300;
function validSignature(header, body) {
if (typeof header !== "string") return false;
const fields = header.split(",").map((p) => p.trim().split("="));
const t = fields.find(([key]) => key === "t")?.[1];
const signatures = fields.filter(([key]) => key === "v1").map(([, value]) => value);
if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > WINDOW_S) return false;
const expected = createHmac("sha256", SECRET).update(`${t}.`).update(body).digest();
return signatures.some((s) => {
const received = Buffer.from(s, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
const seen = new Set(); // in production: a table, with the identifier as a unique key
createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks);
if (!validSignature(req.headers["bastide-signature"], body)) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(body.toString("utf8"));
res.writeHead(204).end(); // answer fast, process afterwards
if (seen.has(event.id)) return;
seen.add(event.id);
console.log(event.type, event.projectId);
});
}).listen(8080);
Retries
- A delivery succeeds on a 2xx response, fully received within 10 seconds. A redirect is not followed: it counts as a failure.
- On failure, Bastide retries 8 times over 24 hours, with exponential backoff:
| Attempt | Time since the first |
|---|---|
| 1 | on emission |
| 2 | +1 min |
| 3 | +5 min |
| 4 | +17 min |
| 5 | +53 min |
| 6 | +2 h 40 min |
| 7 | +8 h |
| 8 | +24 h |
- After the 8th failed attempt, the delivery is dead-lettered. Replay it from the Webhooks screen (Rejouer, replay) or with
webhook_replay_create: one immediate attempt, then back to the dead letters if it fails again. A delivered delivery can be replayed too. - The delivery log keeps the response code, the response body truncated to 4,096 bytes and the duration, for 30 days, like the dead letters.
Limits
They do not depend on the tier.
- At most 25 endpoints per organisation (
quota_de_points_atteint). https://only, with no user name or password in the address.- An internal address (loopback, private network, link-local, cloud metadata) is refused, and checked on every send against the address the name resolves to.
Deploy hooks
The opposite direction exists too: a deploy hook is an address your tool calls to deploy a branch into an environment, with no account or token, each call signed the same way. It is created on the same Webhooks screen, with the Mainteneur (maintainer) rank; its secret (dhsec_…) is shown once, and a project holds at most 20. See Deploy.
Common errors
| Symptom | Cause | Fix |
|---|---|---|
| Every delivery fails, your service rejects the signature | The body was parsed and re-serialised before computing. | Compute over the raw body, as received. |
| Signature rejected now and then | Your server's clock drifts by more than five minutes. | Synchronise the clock (NTP). |
| Deliveries fail on a 301 or 302 response | Redirects are not followed. | Declare the final address. |
| Event processed twice | Delivery is at least once. | Deduplicate on id. |
| Failures after a delay | Your service takes more than 10 seconds to answer. | Answer at once, process afterwards. |