---
title: Deploy webhooks
description: Get a signed HTTP request when a deploy starts, succeeds or fails or a service crashes, verify it, and handle retries, tests and redelivery.
section: Observability
order: 3
---

A deploy webhook sends a signed `POST` to your own URL when something happens to a deployment in a project, so you can post results to Slack, update a status page or start your own tooling. Each project can have up to 20 webhooks.

## Add a webhook

Open the project's **Settings** and choose **Add webhook** under **Deploy webhooks**. Enter the URL, choose the events it should receive and choose **Add webhook**. Dekka then shows the signing secret, which starts with `whsec_`, once, so store it where your endpoint can read it.

The URL must use `https` and point at a public address, and it can't contain a username or password. Dekka refuses anything else with 422 `invalid_webhook_url`. Admins and owners can add, change and delete webhooks, and developers can send tests and redeliver.

## Which events are sent?

| Event | Shown as | Sent when |
| --- | --- | --- |
| `deployment.started` | Deploy started | A deployment leaves `queued` and starts preparing, building or deploying |
| `deployment.succeeded` | Deploy succeeded | A deployment passes its health check and becomes `healthy` |
| `deployment.failed` | Deploy failed | A deployment fails |
| `deployment.crashed` | Service crashed | A running service is reported unhealthy, sent with the deployment that is serving |

A cancelled deployment sends nothing. Choose **Turn off** next to a webhook to pause it, and deliveries that were waiting to be sent fail instead of going out.

## What does a delivery look like?

Each delivery is a `POST` with a JSON body and these headers:

| Header | Value |
| --- | --- |
| `X-Dekka-Event` | The event, such as `deployment.failed` |
| `X-Dekka-Delivery` | The delivery's ID, which stays the same through retries and redeliveries |
| `X-Dekka-Signature` | `t=<unix seconds>,v1=<hex>`, described below |
| `User-Agent` | `Dekka-Webhooks/1` |

The body has the delivery's `id`, the event as `type`, when it happened as `created_at`, and the deployment with its service, environment, project and organisation under `data`.

```json
{
  "created_at": "2026-11-04T09:12:30.512088+00:00",
  "data": {
    "deployment": {
      "branch": "main",
      "commit_message": "Fix the checkout total",
      "commit_sha": "4f2a9c1e8b7d6a5f4e3d2c1b0a9f8e7d6c5b4a39",
      "created_at": "2026-11-04T09:12:28.904113+00:00",
      "error": null,
      "finished_at": null,
      "id": "…",
      "image_ref": null,
      "status": "preparing",
      "trigger": "push"
    },
    "environment": { "id": "…", "name": "production" },
    "organization": { "id": "…", "slug": "acme" },
    "project": { "id": "…", "name": "Shop" },
    "resource": { "id": "…", "kind": "application", "name": "api" }
  },
  "id": "…",
  "type": "deployment.started"
}
```

`status` and `trigger` take the values in [Deployments](/docs/deployments). For `deployment.failed`, `error` holds the failure's `code`, `message` and the `phase` it failed in, such as `building`. `image_ref` is the image that was deployed, which a deployment from GitHub records once it's healthy.

## Verify the signature

Check every request before you trust it. Dekka signs the raw body with HMAC-SHA256, using the whole secret, `whsec_` included, as the key. The signed message is the timestamp from `t`, a full stop, then the body exactly as it arrived, and `v1` is the result in lowercase hex.

Compute the same value and compare it in constant time, and reject a request whose `t` is more than five minutes from your clock so an old request can't be replayed. Each attempt is signed again with a fresh timestamp. Verify the bytes you received, because parsing the JSON and serialising it again changes them.

```js tab="Node"
import crypto from "node:crypto";
import express from "express";

const secret = process.env.DEKKA_WEBHOOK_SECRET;

function verify(header, body) {
  const parts = header.split(",").map((p) => p.trim().split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex"));
  return parts
    .filter(([k]) => k === "v1")
    .some(([, v]) => v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v), expected));
}

const app = express();

app.post("/hooks/dekka", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.get("X-Dekka-Signature") ?? "", req.body)) return res.sendStatus(400);
  const event = JSON.parse(req.body);
  console.log(event.type, event.data.deployment.status);
  res.sendStatus(204);
});

app.listen(3000);
```

```python tab="Python"
import hashlib
import hmac
import os
import time

from fastapi import FastAPI, HTTPException, Request

SECRET = os.environ["DEKKA_WEBHOOK_SECRET"]
app = FastAPI()


def verify(body: bytes, header: str, tolerance: int = 300) -> bool:
    parts: dict[str, list[str]] = {}
    for item in header.split(","):
        key, _, value = item.strip().partition("=")
        parts.setdefault(key, []).append(value)
    try:
        timestamp = int(parts["t"][0])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(SECRET.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, v) for v in parts.get("v1", []))


@app.post("/hooks/dekka", status_code=204)
async def dekka_webhook(request: Request) -> None:
    body = await request.body()
    if not verify(body, request.headers.get("X-Dekka-Signature", "")):
        raise HTTPException(status_code=400)
    event = await request.json()
    print(event["type"], event["data"]["deployment"]["status"])
```

## What if my endpoint is down?

Dekka counts any 2xx answer as delivered. Anything else counts as a failure, including a redirect, which Dekka doesn't follow, and no answer within 5 seconds. Dekka tries again after 30 seconds and waits twice as long before each later try. After 8 attempts, about an hour after the first, the delivery is marked failed.

A delivery can arrive more than once, so use `X-Dekka-Delivery` to ignore one you've already handled. Answer quickly and do slow work after you respond, so a busy handler isn't mistaken for a failure.

## Send a test event

Choose **Send test** next to a webhook. Dekka sends a `webhook.test` event straight away, even when the webhook is turned off, and the result appears in its deliveries. A test isn't retried if it fails.

```json
{
  "created_at": "2026-11-04T09:20:02.118734+00:00",
  "data": {
    "message": "This is a test delivery from Dekka.",
    "project": { "id": "…", "name": "Shop" },
    "webhook_id": "…"
  },
  "id": "…",
  "type": "webhook.test"
}
```

## Redeliver an event

Select a webhook to see its recent deliveries, with the status your endpoint answered and how many attempts each took. Choose **Redeliver** on a failed one and Dekka sends it again with the same `X-Dekka-Delivery` ID and body and a fresh set of attempts. The API also redelivers one that succeeded, with `POST /v1/webhook-deliveries/{id}/redeliver`, and answers 409 `delivery_pending` while a delivery is still being sent.

## Rotate the secret

Select the webhook and choose **Rotate secret**. Dekka shows the new secret once, and signs every delivery with it from then on, including ones still waiting to be retried. The old secret stops working at once, so have your endpoint accept a signature from either secret until you've deployed the new one.

**Delete** removes the webhook and its delivery history.
