> ## Documentation Index
> Fetch the complete documentation index at: https://docs.duraton.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Send a project's events to your own HTTPS endpoint, signed.

A webhook endpoint is a URL of yours that receives some of a project's events
as signed `POST` requests. Each request follows
[Standard Webhooks](https://www.standardwebhooks.com/), so any of its libraries
can check it.

## Create an endpoint

```ts theme={null}
import { createClient } from "@duraton/sdk/client";

const client = createClient({
  url: "https://<slug>.<project-domain>/workflows/v1",
  apiKey: process.env.DURATON_SECRET_KEY ?? "",
});

const endpoint = await client.webhooks.endpoints.create(
  {
    url: "https://example.com/hooks/duraton",
    eventTypes: ["workflows/run.failed", "database/database.paused"],
  },
  { idempotencyKey: "create-orders-hook" },
);
```

The answer carries the endpoint's signing secret, `whsec_` and the base64 of
random bytes. Store it now: it is shown on create and on rotate only, and a
retry with the same idempotency key answers `secret: null`. Send a `secret` of
your own (`whsec_` and the base64 of 24 to 64 bytes) to keep one you already
have.

Endpoints and deliveries take a secret key, as runs do. On the console's
address, `/webhook_endpoints` and `/webhook_deliveries` with `?projectId=`
serve the project's admins and developers, signed in or through OAuth.

## Paths

Under `https://<slug>.<project-domain>/workflows/v1`:

| Method and path | Does |
| - | - |
| `GET /webhook_endpoints` | List endpoints, newest first (`limit`, `cursor`) |
| `POST /webhook_endpoints` | Create one: `url`, `eventTypes`, `name?`, `secret?`; `201` with the secret |
| `GET /webhook_endpoints/{id}` | Read one; never its secret |
| `PATCH /webhook_endpoints/{id}` | Change `url`, `eventTypes`, `name` or `enabled` |
| `DELETE /webhook_endpoints/{id}` | Delete it; nothing more is sent to it |
| `POST /webhook_endpoints/{id}/rotate_secret` | A new secret, answered once; `overlapHours?` |
| `POST /webhook_endpoints/{id}/test` | Send it a `webhooks/endpoint.test` event; `202` with the delivery id |
| `GET /webhook_deliveries` | List deliveries, newest first; `status?`, `endpointId?` and `eventType?` filter, and a page is full unless it is the last |
| `GET /webhook_deliveries/{id}` | One delivery: its body and every attempt |
| `POST /webhook_deliveries/{id}/redeliver` | Send it again now, same event id; `202` |

Every write takes an `Idempotency-Key` header. A retry with the same key and
body never repeats the write: it answers the first result again, or
`409 conflict` when the first attempt was made but its answer was lost (a
`5xx` or a dropped connection). Read the endpoint or the deliveries to see
what it did. The SDK names them `client.webhooks.endpoints` and
`client.webhooks.deliveries`.

## Endpoint URLs

The URL must be `https`, at most 2,048 characters, with no user, password or
fragment, and name a public host: an IP address, `localhost` and cloud metadata
hosts are refused. A refused URL is `422 webhook_url_invalid`, with `reason`
naming the rule:

| Reason | Refused when the URL is |
| - | - |
| `too_long` | Longer than 2,048 characters |
| `malformed` | Not a URL, or holds a character outside printable ASCII |
| `scheme` | Not `https` |
| `userinfo` | Names a user or password |
| `fragment` | Holds a `#` fragment |
| `host_denied` | An IP address, `localhost` or a cloud metadata host |

Each address the host resolves to is checked again when a request is sent, so
a name that later points at a private network is not reached.

## Events

An endpoint receives only the event types it lists:

| Event type | Version |
| - | - |
| `database/database.read_only_entered` | 1 |
| `database/database.read_only_exited` | 1 |
| `database/database.created` | 1 |
| `database/database.deleted` | 1 |
| `database/database.paused` | 1 |
| `database/database.resumed` | 1 |
| `database/export.completed` | 1 |
| `database/restore.completed` | 1 |
| `workflows/run.succeeded` | 1 |
| `workflows/run.failed` | 1 |
| `workflows/run.cancelled` | 1 |
| `workflows/run.finished` | 1 |
| `workflows/token.completed` | 1 |
| `project/key.created` | 1 |
| `project/key.revoked` | 1 |
| `webhooks/endpoint.disabled` | 1 |
| `webhooks/delivery.exhausted` | 1 |

Each request's body is `{ "type", "timestamp", "data" }`, with the event's own
data. A body over 64 KiB is sent with
`data: { "eventId", "subjectId", "truncated": true }` instead; read the event
from its own API.

Order is not guaranteed: use `timestamp` to order events, and `webhook-id`,
the same on every retry and redelivery, to drop a repeat.

## Check a request

Three headers sign each request:

| Header | Holds |
| - | - |
| `webhook-id` | The event's id |
| `webhook-timestamp` | When it was sent, in Unix seconds |
| `webhook-signature` | `v1,` and the base64 HMAC-SHA256 of `id.timestamp.body`; two of them, space separated, while a rotated secret still signs |

Check the raw body, before any JSON parsing:

```ts theme={null}
const TOLERANCE_SECONDS = 5 * 60;

function bytesOf(base64: string): Uint8Array | null {
  try {
    return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
  } catch {
    return null;
  }
}

export async function verifyWebhook(
  secret: string,
  headers: Headers,
  body: string,
  nowMs: number = Date.now(),
): Promise<boolean> {
  const id = headers.get("webhook-id");
  const timestamp = headers.get("webhook-timestamp");
  const signatures = headers.get("webhook-signature");
  if (id === null || timestamp === null || signatures === null) return false;
  const ageSeconds = Math.abs(nowMs / 1000 - Number(timestamp));
  if (!(ageSeconds <= TOLERANCE_SECONDS)) return false;
  const key = bytesOf(secret.replace(/^whsec_/, ""));
  if (key === null) return false;
  const hmac = await crypto.subtle.importKey(
    "raw",
    key,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"],
  );
  const signed = new TextEncoder().encode(`${id}.${timestamp}.${body}`);
  for (const entry of signatures.split(" ")) {
    const [version, signature = ""] = entry.split(",");
    const bytes = bytesOf(signature);
    if (version !== "v1" || bytes === null) continue;
    if (await crypto.subtle.verify("HMAC", hmac, bytes, signed)) return true;
  }
  return false;
}
```

Answer any `2xx` within 15 seconds to take it; do slow work after answering.

## Retries

A request that times out, cannot connect, or answers `408`, `429` or `5xx` is
tried again: immediately, then after 5 seconds, 5 minutes, 30 minutes, 2
hours, 5 hours, 10 hours and 10 hours, each wait spread by up to a fifth
either way. A `Retry-After` header on `429` or `503` is honoured, up to an
hour. A redirect or any other `4xx` stops the delivery (`dead`), and `410
Gone` also turns the endpoint off.

When every attempt is used the delivery is `exhausted` and the project emits
`webhooks/delivery.exhausted`. An endpoint whose every attempt has failed for
5 days is turned off with `disabledReason: "failing"` and
`webhooks/endpoint.disabled` is emitted; turn it on again with
`PATCH { "enabled": true }`.

When your receiver answers `429`, `502` or `504`, the endpoint is slowed:
every endpoint answer then carries `throttleDelayMs`, the pause in
milliseconds, and `throttledUntil`, when it ends. The endpoint is slowed while
`throttleDelayMs` is above 0, even when `throttledUntil` has passed. Both are
left out when the endpoint is not slowed.

A delivery is `pending`, `failed` (waiting for its next attempt),
`succeeded`, `exhausted` or `dead`. Redeliver any of them; one being sent at
that moment is `409 conflict`.

## Rotate a secret

`POST /webhook_endpoints/{id}/rotate_secret` answers a new secret once. The
old one keeps signing beside it for 24 hours, so your receiver can move over;
send `overlapHours` from 0 to 168 to change that, `0` to stop the old secret
at once. A retry with the same `Idempotency-Key` does not rotate again, so the
secret your receiver holds keeps signing until the overlap ends. If that retry
is `409 conflict`, the rotation's new secret is not shown again: rotating once
more gives you one, but stops your receiver's secret at once.

## Send from a workflow

`webhook.send(id, input)` in a workflow's handler is a step: it sends once,
and the handler running again for later steps sends nothing more. It takes an
endpoint or a URL, never both:

| Input | Sends |
| - | - |
| `{ endpointId, type?, data? }` | To one of the project's endpoints, signed with its secret like any event: the body is `{ "type", "timestamp", "data" }` |
| `{ url, data? }` | `data` as the body to the URL, unsigned |

```ts theme={null}
import { defineWorkflow } from "@duraton/sdk";

const BILLING_ENDPOINT = "<endpoint-id>";

export const orderPaid = defineWorkflow<{ orderId: string; total: number }>({
  name: "order-paid",
  triggers: [{ event: "shop/order.paid" }],
  handler: async ({ event, webhook }) => {
    await webhook.send("notify-billing", {
      endpointId: BILLING_ENDPOINT,
      type: "order.paid",
      data: { orderId: event.data.orderId, total: event.data.total },
    });
  },
});
```

`type` is `custom` when left out; otherwise words of letters, digits and `_`
joined by `.`, at most 256 characters. The endpoint receives it whatever
event types it lists. Its `webhook-id` is the delivery's id, it is retried,
throttled and counted toward the endpoint's health like any delivery, and it
shows in the delivery log with the event type `custom`. To an endpoint that
is turned off the delivery is `dead`.

The step resolves once the send is stored, before the request goes out. An
endpoint id the project does not have, a `type` on a URL send, a bad `type`,
data that is not JSON, or a body over 64 KiB is never sent: the run fails with
`error.code` `step_failed` and the reason as its message.

## MCP tools

Both MCP servers offer these tools, as they do the workflows tools: the
project server lists the write tools only while MCP writes are on for the
project's workflows. None takes or shows a signing secret: read a new
endpoint's secret by rotating it through the API.

| Tool | Access | Arguments | What it does |
| - | - | - | - |
| `webhooks_list_endpoints` | Read | `projectId`, `limit?`, `cursor?` | List a project's webhook endpoints, newest first. |
| `webhooks_get_endpoint` | Read | `projectId`, `endpointId` | Get one webhook endpoint: its URL, events and whether it is on. |
| `webhooks_list_deliveries` | Read | `projectId`, `status?`, `endpointId?`, `eventType?`, `limit?`, `cursor?` | List a project's webhook deliveries, newest first, filtered by status, endpoint or event type. |
| `webhooks_get_delivery` | Read | `projectId`, `deliveryId` | Get one webhook delivery with its body and every attempt. |
| `webhooks_create_endpoint` | Write | `projectId`, `url`, `eventTypes`, `name?`, `idempotencyKey?` | Create a webhook endpoint for some of a project's events. Its signing secret is not shown here: rotate it through the API to read it. |
| `webhooks_update_endpoint` | Write | `projectId`, `endpointId`, `url?`, `eventTypes?`, `name?`, `enabled?`, `idempotencyKey?` | Change a webhook endpoint's URL, events or name, or turn it on or off. |
| `webhooks_delete_endpoint` | Write | `projectId`, `endpointId`, `idempotencyKey?` | Delete a webhook endpoint; nothing more is sent to it. |
| `webhooks_send_test` | Write | `projectId`, `endpointId`, `idempotencyKey?` | Send a test event to a webhook endpoint. |
| `webhooks_replay_delivery` | Write | `projectId`, `deliveryId`, `idempotencyKey?` | Send a webhook delivery again now, with the same event id. |

Errors are listed in the [REST reference](https://docs.duraton.dev/workflows/rest-reference.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.