<!-- source: https://secondlayer.tools/docs/sdk-reference/webhooks -->

# `sl.webhooks`

Create webhooks, read their deliveries and replay history. The same data the [Webhooks API](/docs/api/webhooks) serves over HTTP.

## Methods

### sl.webhooks.list()

List your webhooks.

Each summary has the status, format, URL, last delivery and last success, and the circuit breaker state (`circuitFailures`, `circuitOpenedAt`).

REST twin: `GET /api/webhooks`

```ts
sl.webhooks.list(): Promise<{ data: WebhookSummary[] }>
```

### sl.webhooks.get(id)

Get one webhook's full configuration.

Adds the triggers, the receiver's auth (redacted to header names and whether a secret is set), the retry and timeout limits, `lastError`, and a `warning` when this instance's trigger evaluator is not running.

REST twin: `GET /api/webhooks/{id}`

```ts
sl.webhooks.get(id: string): Promise<WebhookDetail>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |

### sl.webhooks.create(input)

Create a webhook that POSTs chain events to your URL.

Takes a `name`, a `url`, and `triggers` built with `trigger.*`; each trigger is validated before anything is provisioned, and an invalid one is a 400. The signing secret comes back once in `signingSecret`, so store it. A webhook starts at the chain tip and never backfills: use `replay` for history. Hosted accounts cap `maxRetries` at 7 and `timeoutMs` at 30000.

REST twin: `POST /api/webhooks`

```ts
sl.webhooks.create(input: CreateWebhookRequest): Promise<CreateWebhookResponse>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | yes |  |
| `triggers` | `ChainTrigger[]` | yes |  |
| `url` | `string` | yes |  |
| `format` | `WebhookFormat` | no |  |
| `authConfig` | `Record<string, unknown>` | no |  |
| `maxRetries` | `number` | no |  |
| `timeoutMs` | `number` | no |  |
| `concurrency` | `number` | no |  |

```ts
import { SecondLayer, trigger } from "@secondlayer/sdk";

const sl = new SecondLayer({
	apiKey: process.env.SECONDLAYER_API_KEY,
});

const { webhook, signingSecret } = await sl.webhooks.create({
	name: "sbtc-mints",
	url: "https://my-app.com/webhook",
	triggers: [
		trigger.ftMint({
			assetIdentifier:
				"SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
		}),
	],
});

console.log(`created ${webhook.id} (${webhook.status})`);
console.log(`WEBHOOK_SECRET=${signingSecret}`);
```

### sl.webhooks.update(id, patch)

Change a webhook's name, URL, format, auth, or delivery limits.

Send only the fields to change: `name`, `url`, `format`, `authConfig`, `maxRetries`, `timeoutMs`, `concurrency`. Triggers are not part of an update. Resolves to the updated webhook.

REST twin: `PATCH /api/webhooks/{id}`

```ts
sl.webhooks.update(id: string, patch: UpdateWebhookRequest): Promise<WebhookDetail>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `patch.name` | `string` | no |  |
| `patch.url` | `string` | no |  |
| `patch.format` | `WebhookFormat` | no |  |
| `patch.authConfig` | `Record<string, unknown>` | no |  |
| `patch.maxRetries` | `number` | no |  |
| `patch.timeoutMs` | `number` | no |  |
| `patch.concurrency` | `number` | no |  |

### sl.webhooks.pause(id)

Stop deliveries without deleting the webhook.

Resolves to the updated webhook. `resume` turns it back on.

REST twin: `POST /api/webhooks/{id}/pause`

```ts
sl.webhooks.pause(id: string): Promise<WebhookDetail>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |

### sl.webhooks.resume(id)

Start delivering again after a pause.

Also clears a circuit-breaker pause, which happens after 20 straight failed deliveries. Resolves to the updated webhook.

REST twin: `POST /api/webhooks/{id}/resume`

```ts
sl.webhooks.resume(id: string): Promise<WebhookDetail>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |

### sl.webhooks.delete(id)

Delete a webhook.

Resolves to `{ ok: true }`. Throws a 404 when the webhook does not exist.

REST twin: `DELETE /api/webhooks/{id}`

```ts
sl.webhooks.delete(id: string): Promise<{ ok: true }>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |

### sl.webhooks.rotateSecret(id)

Issue a new signing secret.

The new secret comes back once in `signingSecret`. Update the receiver that calls `verifyWebhookSignature` with it: deliveries are signed with the new secret from now on.

REST twin: `POST /api/webhooks/{id}/rotate-secret`

```ts
sl.webhooks.rotateSecret(id: string): Promise<RotateSecretResponse>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |

### sl.webhooks.test(id)

Send a one-off test delivery to a webhook's URL.

Built for the webhook's format and SSRF-guarded. Logged as a delivery
row, visible via `deliveries`.

REST twin: `POST /api/webhooks/{id}/test`

```ts
sl.webhooks.test(id: string): Promise<WebhookTestResult>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |

### sl.webhooks.deliveries(id, window)

The last 100 delivery attempts, newest first. The server caps the
result; there is nothing to page. Pass `window` (ISO timestamps, at most
31 days wide) to read only the attempts dispatched in `[from, to)`, for
example one hour of the heat strip; `total` then counts that window.

REST twin: `GET /api/webhooks/{id}/deliveries`

```ts
sl.webhooks.deliveries(
  id: string,
  window?: { from: string; to: string },
): Promise<{ total: number; data: DeliveryRow[] }>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `window.from` | `string` | yes |  |
| `window.to` | `string` | yes |  |

### sl.webhooks.activity(id, opts)

Get a webhook's hourly delivery counts for the last 7 days.

Each hour has delivered, waiting, and gave-up counts, plus the current
queue depth, failed attempts per hour, and the usual band. Pass
`{ days: 30 }` to also get 30 daily totals. This is the events chart on
the detail page.

REST twin: `GET /api/webhooks/{id}/activity`

```ts
sl.webhooks.activity(id: string, opts?: { days?: 30 }): Promise<WebhookActivity>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `opts.days` | `30` | no |  |

### sl.webhooks.delivery(id, deliveryId)

Get one delivery attempt with its payload and response.

Includes the outbox context (payload, response headers). This is the
delivery card on the detail page.

REST twin: `GET /api/webhooks/{id}/deliveries/{deliveryId}`

```ts
sl.webhooks.delivery(id: string, deliveryId: string): Promise<WebhookDeliveryDetail>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `deliveryId` | `string` | yes |  |

### sl.webhooks.replay(id, range)

Re-deliver historical events over an existing webhook.

Pass `fromBlock` and `toBlock`; the range is capped at 100,000 blocks. A replay is historical only and never moves the live cursor. It is idempotent: the same range twice is a no-op, so pass any string as `force` to re-deliver it. Replays carry `is_replay` and use the standard `chain.{type}.apply` envelope. Resolves to `replayId`, `enqueuedCount`, and `scannedCount`. A second replay while one is running is a 409. `sbtc_withdrawal_swept_confirmed` is forward-only and never replayed. Hosted accounts get 5 replays an hour.

REST twin: `POST /api/webhooks/{id}/replay`

```ts
sl.webhooks.replay(
  id: string,
  range: { fromBlock: number; toBlock: number; force?: string },
): Promise<ReplayResult>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `range.fromBlock` | `number` | yes |  |
| `range.toBlock` | `number` | yes |  |
| `range.force` | `string` | no |  |

```ts
import { SecondLayer } from "@secondlayer/sdk";

const id = process.argv[2];
if (!id) throw new Error("usage: bun replay.ts <webhook id>");

const sl = new SecondLayer({
	apiKey: process.env.SECONDLAYER_API_KEY,
});
const range = { fromBlock: 8_000_000, toBlock: 8_050_000 };

const first = await sl.webhooks.replay(id, range);
console.log(first.replayId, first.scannedCount, first.enqueuedCount);

const again = await sl.webhooks.replay(id, { ...range, force: "retry-1" });
console.log(again.replayId);
```

### sl.webhooks.dead(id)

List the events a webhook gave up delivering.

Returns up to 100 dead-lettered events, newest failure first. Each row's `id` is what `requeue` takes.

REST twin: `GET /api/webhooks/{id}/dead`

```ts
sl.webhooks.dead(id: string): Promise<{ data: DeadRow[] }>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |

### sl.webhooks.requeue(id, outboxId)

Push one dead-lettered event back onto the delivery queue. `outboxId` is
the `id` of a row from `dead`.

REST twin: `POST /api/webhooks/{id}/dead/{outboxId}/requeue`

```ts
sl.webhooks.requeue(id: string, outboxId: string): Promise<{ ok: true }>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `outboxId` | `string` | yes |  |

## Types

- `ChainTrigger`: JSON mirror of the subgraph runtime's `SubgraphFilter` union.
- `ChainTriggerAmount`: Non-negative integer amount over JSON (string for uint128 safety, or number).
- `CreateWebhookRequest`: The body for creating a webhook: name, triggers, URL, delivery options.
  - `name`: `string`
  - `triggers`: `ChainTrigger[]`
  - `url`: `string`
  - `format`?: `WebhookFormat`
  - `authConfig`?: `Record<string, unknown>`
  - `maxRetries`?: `number`
  - `timeoutMs`?: `number`
  - `concurrency`?: `number`
- `CreateWebhookResponse`: The new webhook and its signing secret, shown once.
  - `webhook`: `WebhookDetail`
  - `signingSecret`: `string`. Plaintext signing secret — surfaced ONCE. Store it server-side.
- `DeadRow`: A delivery that ran out of retries.
  - `id`: `string`
  - `eventType`: `string`
  - `attempt`: `number`
  - `blockHeight`: `number`
  - `txId`: `string \| null`
  - `payload`: `Record<string, unknown>`
  - `failedAt`: `string \| null`
  - `createdAt`: `string`
- `DeliveryRow`: One delivery attempt: status code, error, duration, and response.
  - `id`: `string`
  - `attempt`: `number`
  - `statusCode`: `number \| null`
  - `errorMessage`: `string \| null`
  - `durationMs`: `number \| null`
  - `responseBody`: `string \| null`
  - `dispatchedAt`: `string`
  - `blockHeight`: `number \| null`. Null for test deliveries and events already compacted out of the outbox.
  - `blockTime`: `string \| null`. ISO timestamp of the delivered event's block. `null` for test deliveries, events already compacted out of the outbox, and rows written before this column existed.
  - `txId`: `string \| null`. Null for test deliveries and events already compacted out of the outbox.
  - `eventIndex`: `number \| null`. The row's position within its block/tx, from the outbox's `row_pk`. Null for test deliveries, compacted rows, and rows with neither shape (a settlement or reorg outbox row).
- `ReplayResult`: How many events a replay scanned and enqueued.
  - `replayId`: `string`
  - `enqueuedCount`: `number`
  - `scannedCount`: `number`
- `RotateSecretResponse`: The webhook and its new signing secret.
  - `webhook`: `WebhookDetail`
  - `signingSecret`: `string`
- `TraitScoped`
  - `trait`?: `string`
- `UpdateWebhookRequest`: The fields of a webhook you can change.
  - `name`?: `string`
  - `url`?: `string`
  - `format`?: `WebhookFormat`
  - `authConfig`?: `Record<string, unknown>`
  - `maxRetries`?: `number`
  - `timeoutMs`?: `number`
  - `concurrency`?: `number`
- `WebhookActivity`: A week of hourly delivery counts and what is waiting now.
  - `hours`: `WebhookActivityHour[]`. 168 hours, oldest first.
  - `waiting`: `number`. Pending rows right now, no time bound.
  - `nextAttemptAt`: `string \| null`
  - `lastSuccessAt`: `string \| null`
  - `byEventType`: `Record<string, number>`. 7-day counts per `event_type` (same 168-hour window as `hours`), e.g. `chain.stx_transfer.apply` — the config card's per-trigger volume.
  - `usualByHourOfDay`: `{ lo: number; hi: number; }[] \| null`. Typical delivered attempts for each UTC hour of day (p10 and p90 of the last 28 days, from `webhook_deliveries`). Null at low volume, where "usual" is not meaningful.
  - `days`?: `WebhookActivityDay[]`. Present with `?days=30`: 30 UTC days, oldest first, from `webhook_deliveries` (2xx is delivered, everything else failed).
- `WebhookActivityDay`: One UTC day of delivery attempts (`GET /:id/activity?days=30`).
  - `day`: `string`. YYYY-MM-DD, UTC.
  - `delivered`: `number`
  - `failed`: `number`
- `WebhookActivityHour`: One hour of `webhook_outbox` activity (`GET /:id/activity`), zero-filled so the events chart always has 168 bars.
  - `hour`: `string`. ISO timestamp, UTC hour start.
  - `delivered`: `number`
  - `waiting`: `number`
  - `gaveUp`: `number`
  - `failed`: `number`. Failed attempts in the hour (no response or non-2xx), from `webhook_deliveries`. Attempts, not events: a retried-then-delivered event counts here but is `delivered` above.
- `WebhookAuth`: What a webhook's receiver credentials look like once redacted for a response — never the bearer token, basic-auth string, or header values themselves, only enough to show what's set.
  - `type`: `"none" \| "bearer" \| "basic"`
  - `headerNames`: `string[]`. Keys of any extra headers the receiver expects — never their values.
  - `hasSecret`: `boolean`. True when a bearer token or basic-auth string is set, whatever it is.
- `WebhookDeliveryDetail`: One delivery attempt with its outbox context.
  - `id`: `string`
  - `attempt`: `number`
  - `statusCode`: `number \| null`
  - `durationMs`: `number \| null`
  - `dispatchedAt`: `string`
  - `errorMessage`: `string \| null`
  - `responseBody`: `string \| null`
  - `responseHeaders`: `Record<string, string> \| null`
  - `outboxId`: `string \| null`
  - `outboxStatus`: `"pending" \| "delivered" \| "dead" \| null`. The outbox row's own status — `null` once it's gone (compacted after delivery, or never had one, e.g. a test delivery). Only `"dead"` means a resend is possible: it's the one status `POST /:id/dead/:outboxId/ requeue` accepts.
  - `eventType`: `string \| null`
  - `txId`: `string \| null`
  - `blockHeight`: `number \| null`
  - `blockTime`: `string \| null`
  - `eventIndex`: `number \| null`. The row's position within its block/tx, from the outbox's `row_pk` (`event_index` for a chain-trigger row). Null when `row_pk` carries none — a settlement or reorg outbox row, or no outbox row at all.
  - `payload`: `unknown`
- `WebhookDetail`: A webhook with its triggers, auth, retry, and timeout settings.
  - `triggers`: `ChainTrigger[]`. Chain-trigger filters.
  - `auth`: `WebhookAuth`
  - `maxRetries`: `number`
  - `timeoutMs`: `number`
  - `concurrency`: `number`
  - `lastError`: `string \| null`
  - `receiverCertExpiresAt`: `string \| null`. When the receiver's TLS certificate expires, from the periodic probe. Null for http receivers, refused or unreachable hosts, and webhooks not probed yet.
  - `warning`: `string \| null`. Set when this instance's chain-trigger evaluator isn't running (`SUBGRAPH_SOURCE` != `streams-index`) — the webhook will never fire until that's fixed. Null otherwise. Surfaced on create (so it's seen immediately) and on every read (so `secondlayer webhooks doctor` reports it for a webhook created before the mismatch, or on an instance whose config changed since).
  - `id`: `string`
  - `name`: `string`
  - `status`: `WebhookStatus`
  - `kind`: `WebhookKind`
  - `format`: `WebhookFormat`
  - `url`: `string`
  - `lastDeliveryAt`: `string \| null`
  - `lastSuccessAt`: `string \| null`
  - `circuitOpenedAt`: `string \| null`. When the circuit breaker paused this webhook (20 straight failures); cleared on resume. Present on the list summary (not detail-only) so a numbers-only insight can be shown per row without a per-row fetch.
  - `circuitFailures`: `number`. Consecutive failed deliveries (reset to 0 on a success). On the summary so the list can show a failing receiver per row without a per-row fetch.
  - `managedBy`: `string \| null`. Owning product, derived from the name prefix (`MANAGED_NAME_PREFIXES`). When non-null the API refuses to rename the webhook.
  - `createdAt`: `string`
  - `updatedAt`: `string`
- `WebhookSummary`: A webhook's name, status, format, and URL.
  - `id`: `string`
  - `name`: `string`
  - `status`: `WebhookStatus`
  - `kind`: `WebhookKind`
  - `format`: `WebhookFormat`
  - `url`: `string`
  - `lastDeliveryAt`: `string \| null`
  - `lastSuccessAt`: `string \| null`
  - `circuitOpenedAt`: `string \| null`. When the circuit breaker paused this webhook (20 straight failures); cleared on resume. Present on the list summary (not detail-only) so a numbers-only insight can be shown per row without a per-row fetch.
  - `circuitFailures`: `number`. Consecutive failed deliveries (reset to 0 on a success). On the summary so the list can show a failing receiver per row without a per-row fetch.
  - `managedBy`: `string \| null`. Owning product, derived from the name prefix (`MANAGED_NAME_PREFIXES`). When non-null the API refuses to rename the webhook.
  - `createdAt`: `string`
  - `updatedAt`: `string`
- `WebhookTestResult`: Result of a one-off test delivery (`POST /:id/test`).
  - `ok`: `boolean`
  - `statusCode`: `number \| null`
  - `error`: `string \| null`
  - `durationMs`: `number`
  - `deliveryId`: `string`

## Notes for agents

- Methods live on 'sl.webhooks' of 'const sl = new SecondLayer()'. Signatures and overloads are generated from the SDK source.
- To check a delivery you receive, use verifyWebhookSignature from the Helpers page, not a method here.
- A method's REST twin is linked on its entry; the Webhooks API page serves the same data over HTTP.

## Setup

Ensure setup once, skipping any step already done:
- Skill: `bunx skills add ryanwaits/secondlayer --skill secondlayer -y`
- CLI: `bun add -g @secondlayer/cli`
- Instance: `secondlayer init` writes `INSTANCE_TOKEN`. Loopback `/v1` reads need no key.
- Hosted/archive: export `SECONDLAYER_API_KEY` (`sk-sl_*`) or buy credits with `secondlayer credits buy` (`balance`, `refill`), then `secondlayer whoami`
