Aller au contenu
Bastide Connexion Créer un compte
Press / to search

Webhooks

FrançaisMarkdownv0.3.0

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 with whk_.
  • 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.

  1. Open the project, then Webhooks.
  2. In "Déclarer un point de réception" (declare an endpoint), enter the address and, if you wish, the event types to receive.
  3. Déclarer le point (declare the endpoint).
  4. 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": { }
}
  • attempt is 1 on the first send and keeps counting from one send to the next, replays included.
  • The headers Bastide-Event-Id, Bastide-Event-Type and Bastide-Attempt repeat these values, next to Bastide-Signature.
  • payload.actor says who made the move: accountId for a person, serviceTokenId for a service account, channel for the route (console, api, cli, mcp, or forge and hook for a hook). When agentSession is not null, an agent acted, and accountId is the person who mandated it. actor is null when no call is behind the fact, for example a deployment becoming healthy.
  • A variable's value is never sent.

Events

TypeWhen
deployment.createdA deployment is created and waits for its build.
deployment.builtThe build produced a signed image.
deployment.readyThe deployment is serving traffic.
deployment.failedThe deployment failed; the cause is read with deployment.read.
deployment.canceledThe deployment was cancelled before serving.
deployment.restoredAn earlier deployment is put back into service.
project.createdA project is created.
project.renamedA project changes name.
project.deletedA project is deleted, at the end of its grace period.
project.anomaly.detectedAn environment enters an anomaly (error rate, latency, saturation, crash loop).
project.egress.thresholdOutbound traffic nears the tier's sustained cap.
project.images.thresholdImage transformations pass 80 % of the tier's monthly cap.
domain.addedA domain is added.
domain.verifiedOwnership of a domain is verified.
domain.misconfiguredThe domain no longer points to the platform; it will be removed in seven days unless it points there again.
domain.deletedA domain stops being served.
variable.created, variable.updated, variable.deletedAn environment variable is created, changes value or is deleted.
database.branch.created, database.branch.destroyedA database branch is created or destroyed.
database.cron.failedA scheduled job in the database failed, or did not start within its window.
database.schema.driftedThe 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>")>
  1. Recompute HMAC-SHA256(secret, t + "." + body) over the raw body, before any JSON parser. The key is the whole secret, whsec_ prefix included.
  2. Accept if any v1 value matches, using a constant-time comparison.
  3. Reject a t more 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:
AttemptTime since the first
1on 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

SymptomCauseFix
Every delivery fails, your service rejects the signatureThe body was parsed and re-serialised before computing.Compute over the raw body, as received.
Signature rejected now and thenYour server's clock drifts by more than five minutes.Synchronise the clock (NTP).
Deliveries fail on a 301 or 302 responseRedirects are not followed.Declare the final address.
Event processed twiceDelivery is at least once.Deduplicate on id.
Failures after a delayYour service takes more than 10 seconds to answer.Answer at once, process afterwards.

See also

Report a mistake on this page