# 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 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`:

```json
{
  "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`:

```json
{
  "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

| 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](../reference/operations/webhook.endpoint.create.md).

## 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:

```js
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](deploy.md).

## 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. |

## See also

- [Deploy](deploy.md)
- [The HTTP API](api.md)
- [Notifications](notifications.md)
- [Audit log](audit.md)
