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

# Webhooks

> Signed CloudEvents from Blank: endpoint management, signature verification, secret rotation, delivery inspection, retries, dead letters, and replay.

# Webhooks

Blank delivers events to your HTTPS endpoint as signed [CloudEvents 1.0](https://cloudevents.io) JSON. Webhooks replace polling for anything you need to react to promptly.

Managing endpoints requires `webhooks:write`; reading endpoints and deliveries requires `webhooks:read`.

## Limits

| Limit                        | Value | Exceeding it                                             |
| ---------------------------- | ----- | -------------------------------------------------------- |
| Endpoints per account        | 20    | `409 webhook_endpoint_limit_reached` — delete one first  |
| Replays per delivery         | 10    | `409 webhook_delivery_replay_limit_reached`              |
| Deliveries claimed per batch | 10    | No error; the rest are claimed in the next dispatch pass |

Deleted endpoints do not count toward the endpoint limit. The per-batch figure is a fair-share bound rather than a quota: dispatch takes at most that many of your deliveries per pass so one account's backlog cannot delay everyone else's, and your remaining deliveries are picked up on subsequent passes in the usual order.

## Operations

| Operation               | HTTP                                                    | SDK                                                       | Scope            | Success |
| ----------------------- | ------------------------------------------------------- | --------------------------------------------------------- | ---------------- | ------- |
| `createWebhookEndpoint` | `POST /webhook-endpoints`                               | `blank.webhooks.create(input, options)`                   | `webhooks:write` | `201`   |
| `listWebhookEndpoints`  | `GET /webhook-endpoints`                                | `blank.webhooks.list(query)`                              | `webhooks:read`  | `200`   |
| `getWebhookEndpoint`    | `GET /webhook-endpoints/{endpointId}`                   | `blank.webhooks.get(endpointId)`                          | `webhooks:read`  | `200`   |
| `updateWebhookEndpoint` | `PATCH /webhook-endpoints/{endpointId}`                 | `blank.webhooks.update(endpointId, input, options)`       | `webhooks:write` | `200`   |
| `deleteWebhookEndpoint` | `DELETE /webhook-endpoints/{endpointId}`                | `blank.webhooks.delete(endpointId, version, options)`     | `webhooks:write` | `200`   |
| `rotateWebhookSecret`   | `POST /webhook-endpoints/{endpointId}/secret-rotations` | `blank.webhooks.rotateSecret(endpointId, input, options)` | `webhooks:write` | `201`   |
| `listWebhookDeliveries` | `GET /webhook-endpoints/{endpointId}/deliveries`        | `blank.webhooks.deliveries(endpointId, query)`            | `webhooks:read`  | `200`   |
| `replayWebhookDelivery` | `POST /webhook-deliveries/{deliveryId}/replays`         | `blank.webhooks.replay(deliveryId, options)`              | `webhooks:write` | `202`   |

All mutations require an `Idempotency-Key`. Update, delete, and rotate additionally require `If-Match` with the endpoint's current `version`.

## Event types

| Event type                                 | Fires when                      |
| ------------------------------------------ | ------------------------------- |
| `build.blank.launch.created.v1`            | A launch record is created      |
| `build.blank.launch.confirmed.v1`          | A launch is confirmed on-chain  |
| `build.blank.trade.confirmed.v1`           | A trade is confirmed            |
| `build.blank.staking.position.updated.v1`  | A staking position changes      |
| `build.blank.fee.distribution.created.v1`  | A fee distribution is booked    |
| `build.blank.prediction.submitted.v1`      | A price prediction is accepted  |
| `build.blank.prediction.round.locked.v1`   | A prediction round locks        |
| `build.blank.prediction.round.settled.v1`  | A prediction round settles      |
| `build.blank.presale.updated.v1`           | Presale state changes           |
| `build.blank.audience-export.completed.v1` | An audience export finishes     |
| `build.blank.operation.failed.v1`          | An asynchronous operation fails |

Types are versioned with a `.v1` suffix. A breaking payload change ships as a new type, never as a mutation of an existing one.

## Create an endpoint

```ts theme={null}
const created = await blank.webhooks.create({
  name: "prediction-bot",
  url: "https://hooks.example.com/blank",
  eventTypes: [
    "build.blank.prediction.round.locked.v1",
    "build.blank.prediction.round.settled.v1",
  ],
});

console.log(created.data.endpoint.id);
console.log(created.data.secret); // whsec_... — shown once
```

| Field        | Rules                                |
| ------------ | ------------------------------------ |
| `name`       | 1–80 characters                      |
| `url`        | HTTPS only, see the URL policy below |
| `eventTypes` | 1 to 11 values from the table above  |

<Warning>
  The signing secret is returned exactly once, at creation and at rotation.
  Store it in your secret manager immediately. If you lose it, rotate — there is
  no way to read it back.
</Warning>

### URL policy

Endpoint URLs are validated at creation and again on every delivery attempt. A URL is rejected with `422 webhook_url_disallowed` unless it:

* uses `https:` on the default port (no port, or `443`)
* carries no userinfo and no fragment
* resolves to a public, fully qualified hostname — not `localhost`, and not a hostname ending in `.localhost`, `.local`, `.internal`, `.home.arpa`, or `.onion`
* is not a private, loopback, link-local, carrier-grade NAT, documentation, or multicast IP address, in IPv4 or IPv6

Redirects are followed at most three times, and every hop is re-validated against the same policy.

## The delivery request

Blank sends `POST` with these headers:

| Header                  | Value                                                         |
| ----------------------- | ------------------------------------------------------------- |
| `Content-Type`          | `application/json; charset=utf-8`                             |
| `Blank-Event-Id`        | Immutable event UUID — use it to deduplicate                  |
| `Blank-Delivery-Id`     | This delivery attempt's UUID                                  |
| `Blank-Event-Type`      | The event type                                                |
| `Blank-Signature`       | `t=<unix seconds>,v1=<hex>` (plus `v0=<hex>` during rotation) |
| `Blank-Webhook-Version` | `2026-08-01`                                                  |

The body is a CloudEvent:

```json theme={null}
{
  "specversion": "1.0",
  "id": "b7a1f7f0-9d0b-4a5a-8f2f-9a3f1c2d4e5f",
  "source": "https://api.blank.build",
  "type": "build.blank.prediction.round.settled.v1",
  "subject": "round:24c85940-7968-4b71-92dd-0d8f017cea5a",
  "time": "2026-08-10T12:00:00.000Z",
  "datacontenttype": "application/json",
  "data": {}
}
```

`data` carries the allowlisted payload for that event type. Treat unknown fields as additive and ignore them.

## Verifying the signature

`Blank-Signature` is an HMAC-SHA-256 over `"{timestamp}.{raw request body}"`, hex-encoded. Verify the **exact raw bytes** before parsing JSON — re-serializing the body changes the signature.

```ts theme={null}
import { verifyWebhookSignature } from "@blankdotbuild/sdk";

export async function handler(request: Request): Promise<Response> {
  const rawBody = await request.text();

  const valid = await verifyWebhookSignature({
    rawBody,
    signatureHeader: request.headers.get("blank-signature") ?? "",
    secret: process.env.BLANK_WEBHOOK_SECRET!,
    previousSecret: process.env.BLANK_PREVIOUS_WEBHOOK_SECRET,
  });

  if (!valid) return new Response("invalid signature", { status: 401 });

  const event = JSON.parse(rawBody);
  await durablyAccept(event); // dedupe on event.id

  return new Response(null, { status: 204 });
}
```

`verifyWebhookSignature` enforces a timestamp tolerance of **300 seconds** by default, rejecting replays outside that window. Override it with `toleranceSeconds` (1 to 3,600) only if you have a reason to. It checks every `v1` signature against `secret`, and every `v0` signature against `previousSecret` when you supply one.

<Warning>
  Do not implement string comparison of signatures by hand. If you must verify
  outside the SDK, use a constant-time comparison and reject any request whose
  timestamp is outside your tolerance window.
</Warning>

## Responding

* Return any `2xx` **only after the event is durably accepted**. Write it to a queue or a table first, then acknowledge.
* Anything other than `2xx`, a timeout, or a transport failure counts as a failed attempt and is retried.
* The delivery timeout is **10 seconds**. Do the work asynchronously.
* Deduplicate on `Blank-Event-Id`. Retries and replays reuse the same event ID with a new delivery ID, and at-least-once delivery is the contract.

## Retries and dead letters

Each delivery gets **up to 7 attempts**: the first, then six retries at roughly 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, and 24 hours. Each delay is jittered, and a `Retry-After` header on your response is honoured up to 24 hours.

Delivery status moves through:

| Status        | Meaning                                                    |
| ------------- | ---------------------------------------------------------- |
| `pending`     | Queued for its first attempt                               |
| `processing`  | An attempt is in flight                                    |
| `succeeded`   | Your endpoint returned `2xx`                               |
| `retrying`    | The attempt failed and another is scheduled                |
| `dead_letter` | All attempts were exhausted; no further automatic delivery |

## Inspecting deliveries

```ts theme={null}
const failed = await blank.webhooks.deliveries(endpointId, {
  status: "dead_letter",
  limit: 100,
});

for (const delivery of failed.data.data) {
  console.log(
    delivery.eventType,
    delivery.attemptCount,
    delivery.responseStatus,
    delivery.lastErrorCode
  );
}
```

Delivery fields: `id`, `endpointId`, `eventId`, `eventType`, `status`, `attemptCount`, `nextAttemptAt`, `responseStatus`, `lastErrorCode`, `deliveredAt`, `createdAt`, `updatedAt`. Filter by `status` and paginate with `cursor` and `limit`.

`lastErrorCode` distinguishes transport failures (`request_timeout`, `network_error`, `url_disallowed`, `redirect_limit_exceeded`, `redirect_location_missing`) from HTTP rejections, which are recorded as `http_<status>`. The complete stable list is in [Errors](/docs/reference/errors#webhook-delivery-result-codes).

## Replaying

```ts theme={null}
const replayed = await blank.webhooks.replay(deliveryId);
```

A replay creates a **fresh delivery for the same immutable event**, with a new `Blank-Delivery-Id` and the original `Blank-Event-Id`. Use it to drain dead letters after fixing your endpoint. A delivery that is not in a replayable state returns `409 webhook_delivery_not_replayable`; an unknown or foreign delivery returns `404 webhook_delivery_not_found`.

## Rotating the signing secret

Rotation issues a new secret and keeps the previous one valid for a bounded overlap window, so you can deploy without dropping in-flight deliveries.

```ts theme={null}
const endpoint = await blank.webhooks.get(endpointId);

const rotated = await blank.webhooks.rotateSecret(endpointId, {
  overlapHours: 24,
  version: endpoint.data.version,
});

console.log(rotated.data.secret); // the new whsec_... — shown once
```

`overlapHours` is an integer from **1 to 24**, defaulting to 24. During the overlap, Blank signs each delivery with both secrets: `v1` with the new secret and `v0` with the previous one.

Safe rotation:

1. Rotate and store the new secret as `BLANK_WEBHOOK_SECRET`.
2. Move the old value to `BLANK_PREVIOUS_WEBHOOK_SECRET` and deploy, so your handler accepts both.
3. Once the overlap window closes, remove `BLANK_PREVIOUS_WEBHOOK_SECRET`.

## Updating and deleting

Both use optimistic concurrency:

```ts theme={null}
const endpoint = await blank.webhooks.get(endpointId);

const disabled = await blank.webhooks.update(endpointId, {
  status: "disabled",
  version: endpoint.data.version,
});

// Every successful mutation bumps the version. Use the one you just received.
await blank.webhooks.delete(endpointId, disabled.data.version);
```

`update` accepts any non-empty subset of `name`, `url`, `eventTypes`, and `status` (`active` or `disabled`). A stale version returns `412 precondition_failed` — re-read the endpoint and retry.

`delete` soft-deletes the endpoint, stops all future fanout, and returns `{ id, deletedAt }`.

## Errors

| Status | Code                              | Meaning                                       |
| ------ | --------------------------------- | --------------------------------------------- |
| 404    | `webhook_endpoint_not_found`      | Unknown endpoint, or owned by another account |
| 404    | `webhook_delivery_not_found`      | Unknown delivery, or owned by another account |
| 409    | `webhook_delivery_not_replayable` | The delivery is not in a replayable state     |
| 412    | `precondition_failed`             | `If-Match` did not match the current version  |
| 422    | `webhook_url_disallowed`          | The URL violates the endpoint URL policy      |
| 400    | `cursor_invalid`                  | Malformed or mismatched pagination cursor     |

Shared errors apply too. See [Errors](/docs/reference/errors) and [Conventions](/docs/for-developers/conventions).
