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

# Webhooks API

Create webhooks, read their deliveries, and recover what a receiver missed. These endpoints are the `/api/webhooks` write plane of a self-hosted instance; the guide to receiving and verifying is [Webhooks](/docs/webhooks).

## Manage

Create, change, pause and delete a webhook.

### List webhooks

`GET /api/webhooks` (self-hosted instances only)

Every webhook on this instance, newest first, with status and last delivery times. Paged by offset.

#### Filters

| Name | Type | Description |
| --- | --- | --- |
| `_offset` | integer | Rows to skip. Default 0. |

#### Range and paging

| Name | Type | Description |
| --- | --- | --- |
| `_limit` | integer | Page size, 1 to 200. Default 50. |

#### Returns

A cursor envelope; `data` holds `WebhookSummary` objects.

#### Responses

- `200` One page of webhooks
- `401` This bind requires the instance token, and it was missing or wrong

#### Example

```bash
curl http://127.0.0.1:3800/api/webhooks \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "data": [
    {
      "id": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
      "name": "sbtc-moves",
      "status": "active",
      "kind": "chain",
      "format": "standard-webhooks",
      "url": "https://example.com/webhooks/sbtc",
      "lastDeliveryAt": "2026-09-22T14:03:11.402Z",
      "lastSuccessAt": "2026-09-22T14:03:11.402Z",
      "circuitOpenedAt": null,
      "circuitFailures": 0,
      "managedBy": null,
      "createdAt": "2026-09-20T09:12:45.118Z",
      "updatedAt": "2026-09-20T09:12:45.118Z"
    }
  ]
}
```

### Create a webhook

`POST /api/webhooks` (self-hosted instances only)

Takes `triggers` and starts at the chain tip, with no backfill. The response carries the signing secret. This is the only time it is returned, so store it now; `rotate-secret` issues a new one.

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `name` | string, required | Unique name on this instance. |
| `url` | string, required | Delivery target, `http://` or `https://`. Private addresses are refused at delivery time. |
| `triggers` | array of ChainTrigger, required | 1 to 50 triggers. An event matching any of them is delivered. |
| `format` | enum | Payload and signing format. `standard-webhooks` signs with the webhook's secret; the others shape the body for that receiver. |
| `authConfig` | object | Per-format receiver credentials: `token` / `tokenEnc` for `trigger` and `cloudflare`, `headers` or `basicAuth` for `raw`. Write-only — never returned; a read gets `auth` instead. |
| `maxRetries` | integer · 0 to 100 | Attempts before an event moves to the dead-letter queue. Default 7. |
| `timeoutMs` | integer · 100 to 300000 | Per-attempt HTTP timeout in milliseconds. Default 10000. |
| `concurrency` | integer · 1 to 100 | Deliveries in flight at once for this webhook. Default 4. |

#### Returns

One `WebhookWithSecret` object.

#### Responses

- `201` Created. The body carries the signing secret, once
- `400` A parameter, filter, cursor or body field was refused; `code` says which. A body carrying `subgraphName`, `tableName` or `filter` is answered with `SUBGRAPH_WEBHOOKS_REMOVED`: subgraph webhooks were removed
- `401` This bind requires the instance token, and it was missing or wrong
- `409` A webhook with that name already exists
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`
- `500` The webhook could not be stored (`INTERNAL_ERROR`)

#### Example

```bash
curl -X POST http://127.0.0.1:3800/api/webhooks \
  -H "Authorization: Bearer $INSTANCE_TOKEN" \
  --json '{
  "name": "sbtc-moves",
  "url": "https://example.com/webhooks/sbtc",
  "triggers": [
    {
      "type": "ft_transfer",
      "assetIdentifier": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
      "minAmount": "100000"
    },
    {
      "type": "print_event",
      "contractId": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry"
    }
  ]
}'
```

```json
{
  "webhook": {
    "id": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
    "name": "sbtc-moves",
    "status": "active",
    "kind": "chain",
    "format": "standard-webhooks",
    "url": "https://example.com/webhooks/sbtc",
    "lastDeliveryAt": "2026-09-22T14:03:11.402Z",
    "lastSuccessAt": "2026-09-22T14:03:11.402Z",
    "circuitOpenedAt": null,
    "circuitFailures": 0,
    "managedBy": null,
    "createdAt": "2026-09-20T09:12:45.118Z",
    "updatedAt": "2026-09-20T09:12:45.118Z",
    "triggers": [
      {
        "type": "ft_transfer",
        "assetIdentifier": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
        "minAmount": "100000"
      },
      {
        "type": "print_event",
        "contractId": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry"
      }
    ],
    "auth": {
      "type": "none",
      "headerNames": [],
      "hasSecret": false
    },
    "maxRetries": 7,
    "timeoutMs": 10000,
    "concurrency": 4,
    "lastError": null,
    "receiverCertExpiresAt": "2026-12-25T22:56:35.000Z",
    "warning": null
  },
  "signingSecret": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
}
```

### Get a webhook

`GET /api/webhooks/{id}` (self-hosted instances only)

Full config and delivery health for one webhook. Never includes the signing secret.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Returns

One `Webhook` object.

#### Responses

- `200` The webhook
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id

#### Example

```bash
curl http://127.0.0.1:3800/api/webhooks/{id} \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "id": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
  "name": "sbtc-moves",
  "status": "active",
  "kind": "chain",
  "format": "standard-webhooks",
  "url": "https://example.com/webhooks/sbtc",
  "lastDeliveryAt": "2026-09-22T14:03:11.402Z",
  "lastSuccessAt": "2026-09-22T14:03:11.402Z",
  "circuitOpenedAt": null,
  "circuitFailures": 0,
  "managedBy": null,
  "createdAt": "2026-09-20T09:12:45.118Z",
  "updatedAt": "2026-09-20T09:12:45.118Z",
  "triggers": [
    {
      "type": "ft_transfer",
      "assetIdentifier": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
      "minAmount": "100000"
    },
    {
      "type": "print_event",
      "contractId": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry"
    }
  ],
  "auth": {
    "type": "none",
    "headerNames": [],
    "hasSecret": false
  },
  "maxRetries": 7,
  "timeoutMs": 10000,
  "concurrency": 4,
  "lastError": null,
  "receiverCertExpiresAt": "2026-12-25T22:56:35.000Z",
  "warning": null
}
```

### Update a webhook

`PATCH /api/webhooks/{id}` (self-hosted instances only)

Change any subset of fields; at least one is required. `triggers` are fixed at create: to change them, create a new webhook.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `name` | string | New unique name. |
| `url` | string | New delivery target, `http://` or `https://`. |
| `format` | enum | Payload and signing format. `standard-webhooks` signs with the webhook's secret; the others shape the body for that receiver. |
| `authConfig` | object | Per-format receiver credentials: `token` / `tokenEnc` for `trigger` and `cloudflare`, `headers` or `basicAuth` for `raw`. Write-only — never returned; a read gets `auth` instead. |
| `maxRetries` | integer · 0 to 100 | Attempts before an event moves to the dead-letter queue. Default 7. |
| `timeoutMs` | integer · 100 to 300000 | Per-attempt HTTP timeout in milliseconds. Default 10000. |
| `concurrency` | integer · 1 to 100 | Deliveries in flight at once for this webhook. Default 4. |

#### Returns

One `Webhook` object.

#### Responses

- `200` The updated webhook
- `400` A parameter, filter, cursor or body field was refused; `code` says which. A body carrying `subgraphName`, `tableName` or `filter` is answered with `SUBGRAPH_WEBHOOKS_REMOVED`: subgraph webhooks were removed
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X PATCH http://127.0.0.1:3800/api/webhooks/{id} \
  -H "Authorization: Bearer $INSTANCE_TOKEN" \
  --json '{
  "url": "https://example.com/webhooks/sbtc-v2",
  "maxRetries": 10
}'
```

```json
{
  "id": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
  "name": "sbtc-moves",
  "status": "active",
  "kind": "chain",
  "format": "standard-webhooks",
  "url": "https://example.com/webhooks/sbtc",
  "lastDeliveryAt": "2026-09-22T14:03:11.402Z",
  "lastSuccessAt": "2026-09-22T14:03:11.402Z",
  "circuitOpenedAt": null,
  "circuitFailures": 0,
  "managedBy": null,
  "createdAt": "2026-09-20T09:12:45.118Z",
  "updatedAt": "2026-09-20T09:12:45.118Z",
  "triggers": [
    {
      "type": "ft_transfer",
      "assetIdentifier": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
      "minAmount": "100000"
    },
    {
      "type": "print_event",
      "contractId": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry"
    }
  ],
  "auth": {
    "type": "none",
    "headerNames": [],
    "hasSecret": false
  },
  "maxRetries": 7,
  "timeoutMs": 10000,
  "concurrency": 4,
  "lastError": null,
  "receiverCertExpiresAt": "2026-12-25T22:56:35.000Z",
  "warning": null
}
```

### Delete a webhook

`DELETE /api/webhooks/{id}` (self-hosted instances only)

Removes the webhook. Nothing more is delivered to its URL. Cannot be undone.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Responses

- `200` Deleted
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X DELETE http://127.0.0.1:3800/api/webhooks/{id} \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "ok": true
}
```

### Pause a webhook

`POST /api/webhooks/{id}/pause` (self-hosted instances only)

Sets `status` to `paused`. Matched events keep queueing and deliver on resume. Takes no body.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Returns

One `Webhook` object.

#### Responses

- `200` The paused webhook
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X POST http://127.0.0.1:3800/api/webhooks/{id}/pause \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "id": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
  "name": "sbtc-moves",
  "status": "active",
  "kind": "chain",
  "format": "standard-webhooks",
  "url": "https://example.com/webhooks/sbtc",
  "lastDeliveryAt": "2026-09-22T14:03:11.402Z",
  "lastSuccessAt": "2026-09-22T14:03:11.402Z",
  "circuitOpenedAt": null,
  "circuitFailures": 0,
  "managedBy": null,
  "createdAt": "2026-09-20T09:12:45.118Z",
  "updatedAt": "2026-09-20T09:12:45.118Z",
  "triggers": [
    {
      "type": "ft_transfer",
      "assetIdentifier": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
      "minAmount": "100000"
    },
    {
      "type": "print_event",
      "contractId": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry"
    }
  ],
  "auth": {
    "type": "none",
    "headerNames": [],
    "hasSecret": false
  },
  "maxRetries": 7,
  "timeoutMs": 10000,
  "concurrency": 4,
  "lastError": null,
  "receiverCertExpiresAt": "2026-12-25T22:56:35.000Z",
  "warning": null
}
```

### Resume a webhook

`POST /api/webhooks/{id}/resume` (self-hosted instances only)

Sets `status` to `active` and resets the circuit breaker (`circuitFailures`, `circuitOpenedAt`). Queued events start delivering. Takes no body.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Returns

One `Webhook` object.

#### Responses

- `200` The resumed webhook
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X POST http://127.0.0.1:3800/api/webhooks/{id}/resume \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "id": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
  "name": "sbtc-moves",
  "status": "active",
  "kind": "chain",
  "format": "standard-webhooks",
  "url": "https://example.com/webhooks/sbtc",
  "lastDeliveryAt": "2026-09-22T14:03:11.402Z",
  "lastSuccessAt": "2026-09-22T14:03:11.402Z",
  "circuitOpenedAt": null,
  "circuitFailures": 0,
  "managedBy": null,
  "createdAt": "2026-09-20T09:12:45.118Z",
  "updatedAt": "2026-09-20T09:12:45.118Z",
  "triggers": [
    {
      "type": "ft_transfer",
      "assetIdentifier": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
      "minAmount": "100000"
    },
    {
      "type": "print_event",
      "contractId": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry"
    }
  ],
  "auth": {
    "type": "none",
    "headerNames": [],
    "hasSecret": false
  },
  "maxRetries": 7,
  "timeoutMs": 10000,
  "concurrency": 4,
  "lastError": null,
  "receiverCertExpiresAt": "2026-12-25T22:56:35.000Z",
  "warning": null
}
```

### Rotate the signing secret

`POST /api/webhooks/{id}/rotate-secret` (self-hosted instances only)

Issues a new signing secret and returns it, once. Deliveries signed with the old secret stop verifying immediately, so update your receiver right away. Takes no body.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Returns

One `WebhookWithSecret` object.

#### Responses

- `200` The webhook and its new signing secret
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X POST http://127.0.0.1:3800/api/webhooks/{id}/rotate-secret \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "webhook": {
    "id": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
    "name": "sbtc-moves",
    "status": "active",
    "kind": "chain",
    "format": "standard-webhooks",
    "url": "https://example.com/webhooks/sbtc",
    "lastDeliveryAt": "2026-09-22T14:03:11.402Z",
    "lastSuccessAt": "2026-09-22T14:03:11.402Z",
    "circuitOpenedAt": null,
    "circuitFailures": 0,
    "managedBy": null,
    "createdAt": "2026-09-20T09:12:45.118Z",
    "updatedAt": "2026-09-20T09:12:45.118Z",
    "triggers": [
      {
        "type": "ft_transfer",
        "assetIdentifier": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
        "minAmount": "100000"
      },
      {
        "type": "print_event",
        "contractId": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry"
      }
    ],
    "auth": {
      "type": "none",
      "headerNames": [],
      "hasSecret": false
    },
    "maxRetries": 7,
    "timeoutMs": 10000,
    "concurrency": 4,
    "lastError": null,
    "receiverCertExpiresAt": "2026-12-25T22:56:35.000Z",
    "warning": null
  },
  "signingSecret": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
}
```

## Deliveries

Send a test and read every attempt.

### Send a test delivery

`POST /api/webhooks/{id}/test` (self-hosted instances only)

Posts one sample event, built and signed in the webhook's format, to its URL through the SSRF guard, and logs it under deliveries. The body has `data.test: true`, so receivers can tell it from a live event. Works while paused. Takes no body.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Returns

One `WebhookTestResult` object.

#### Responses

- `200` What the target answered. A failed delivery still returns 200; check `ok`
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X POST http://127.0.0.1:3800/api/webhooks/{id}/test \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "ok": true,
  "statusCode": 200,
  "error": null,
  "durationMs": 138,
  "deliveryId": "c7a0e5d2-1f4b-4e8a-9d36-0b5f2a7c9e14"
}
```

### List delivery attempts

`GET /api/webhooks/{id}/deliveries` (self-hosted instances only)

The last 100 delivery attempts, newest first, including test deliveries. `total` counts every attempt ever logged for the webhook. With `from` and `to`, only attempts dispatched in that window; `total` counts the window.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `from` | string | Window start, inclusive, ISO 8601. Needs `to`; at most 31 days before it. |
| `to` | string | Window end, exclusive, ISO 8601. Needs `from`. |

#### Returns

A cursor envelope; `data` holds `WebhookDelivery` objects.

#### Responses

- `200` Recent delivery attempts
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id

#### Example

```bash
curl http://127.0.0.1:3800/api/webhooks/{id}/deliveries \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "total": 4182,
  "data": [
    {
      "id": "9b2e41c7-5d0a-4f3e-8c61-2a7f90d4e3b5",
      "seq": 4182,
      "attempt": 1,
      "statusCode": 200,
      "blockHeight": 8700076,
      "blockTime": "2026-09-22T14:02:58.000Z",
      "errorMessage": null,
      "durationMs": 142,
      "responseBody": "{\"received\":true}",
      "dispatchedAt": "2026-09-22T14:03:11.402Z"
    }
  ]
}
```

## Recovery

Requeue dead events, replay a block range and watch it.

### List dead-lettered events

`GET /api/webhooks/{id}/dead` (self-hosted instances only)

Events that used up `maxRetries` without a 2xx, newest failure first, up to 100. Requeue one to try it again.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Returns

A cursor envelope; `data` holds `WebhookDeadEvent` objects.

#### Responses

- `200` Dead-lettered events
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id

#### Example

```bash
curl http://127.0.0.1:3800/api/webhooks/{id}/dead \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "data": [
    {
      "id": "5e8d3b17-a2c4-4f90-b6e1-7d0c29f4a853",
      "eventType": "chain.ft_transfer.apply",
      "attempt": 7,
      "blockHeight": 8700076,
      "txId": "0x839ef4720020f5aba341630e8965851f8537bc8fc99a856091093780c886a8be",
      "payload": {
        "action": "apply",
        "block_hash": "0xf9ff01e876f9276abc9f0fc79a4fbefc500bf2b93ed1effd0ef637ef84e10604",
        "block_height": 8700076,
        "block_time": "2026-05-01T11:59:48.000Z",
        "tx_id": "0x839ef4720020f5aba341630e8965851f8537bc8fc99a856091093780c886a8be",
        "canonical": true,
        "trigger": "ft_transfer",
        "event": {
          "tx_id": "0x839ef4720020f5aba341630e8965851f8537bc8fc99a856091093780c886a8be",
          "type": "ft_transfer_event",
          "event_index": 45,
          "data": {
            "amount": "764749",
            "sender": "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-stx-sbtc-v-1-bps-15",
            "recipient": "SP1GBRTAXY96ZDYRQY4GR0M9JTXVYD2FGFRGV60FJ",
            "asset_identifier": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token"
          }
        }
      },
      "failedAt": "2026-09-22T16:41:02.877Z",
      "createdAt": "2026-09-22T14:03:10.915Z"
    }
  ]
}
```

### Requeue a dead event

`POST /api/webhooks/{id}/dead/{outbox_id}/requeue` (self-hosted instances only)

Moves one dead event back to pending with its attempt count reset. It delivers at live priority, even if it came from a replay. Takes no body.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |
| `outbox_id` | string, required | Dead event id (UUID), the `id` from the dead-letter list. |

#### Responses

- `200` Requeued
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id, or no dead event with this id on it
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X POST http://127.0.0.1:3800/api/webhooks/{id}/dead/{outbox_id}/requeue \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "ok": true
}
```

### Replay a block range

`POST /api/webhooks/{id}/replay` (self-hosted instances only)

Queues historical events in a block range for redelivery, capped at 100,000 blocks. Replays are idempotent: the same range yields the same `replayId` and delivers nothing twice unless `force` is set. They drain through a 10% share of the outbox so live traffic keeps priority, and never move the live cursor. Replays deliver the normal `chain.<type>.apply` envelope. Watch one with `GET /api/webhooks/{id}/replays/{replay_id}`.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `fromBlock` | integer, required | First block, inclusive. |
| `toBlock` | integer, required | Last block, inclusive. At most 100,000 blocks after `fromBlock`. |
| `force` | string | Any string, up to 64 characters. Makes a new `replayId`, so an already-replayed range delivers again. |

#### Returns

One `WebhookReplayResult` object.

#### Responses

- `202` Replay queued
- `400` A parameter, filter, cursor or body field was refused; `code` says which. Also: `fromBlock` after `toBlock`, or a range over 100,000 blocks
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id
- `409` A replay of this webhook is still scanning its range
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`
- `500` The replay could not be queued (`INTERNAL_ERROR`)

#### Example

```bash
curl -X POST http://127.0.0.1:3800/api/webhooks/{id}/replay \
  -H "Authorization: Bearer $INSTANCE_TOKEN" \
  --json '{
  "fromBlock": 8650000,
  "toBlock": 8700000
}'
```

```json
{
  "replayId": "a41f09c3d27e8b65",
  "enqueuedCount": 312,
  "scannedCount": 4870
}
```

### List recent replays across webhooks

`GET /api/webhooks/replays` (self-hosted instances only)

Replays still scanning or delivering, plus any started in the last 24 hours, across every webhook on this instance. Newest first.

#### Range and paging

| Name | Type | Description |
| --- | --- | --- |
| `_limit` | integer | Page size, 1 to 50. Default 20. |

#### Returns

A cursor envelope; `data` holds `WebhookReplayProgress` objects.

#### Responses

- `200` Recent replays with live progress
- `401` This bind requires the instance token, and it was missing or wrong

#### Example

```bash
curl http://127.0.0.1:3800/api/webhooks/replays \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "data": [
    {
      "replayId": "a41f09c3d27e8b65",
      "webhookId": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
      "status": "done",
      "fromBlock": 8650000,
      "toBlock": 8700000,
      "total": 312,
      "processed": 312,
      "pending": 0,
      "delivered": 309,
      "gaveUp": 3,
      "startedAt": "2026-09-22T14:00:02.000Z",
      "finishedAt": "2026-09-22T14:06:40.000Z",
      "error": null
    }
  ]
}
```

### List a webhook's replays and replay ranges

`GET /api/webhooks/{id}/replays` (self-hosted instances only)

The webhook's last 10 replays with live progress, newest first, and the block ranges a new replay can start from. Ranges count back from the last block the evaluator processed.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Returns

A cursor envelope; `data` holds `WebhookReplayProgress` objects.

#### Responses

- `200` Replays and ranges for this webhook
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id

#### Example

```bash
curl http://127.0.0.1:3800/api/webhooks/{id}/replays \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "data": [
    {
      "replayId": "a41f09c3d27e8b65",
      "webhookId": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
      "status": "done",
      "fromBlock": 8650000,
      "toBlock": 8700000,
      "total": 312,
      "processed": 312,
      "pending": 0,
      "delivered": 309,
      "gaveUp": 3,
      "startedAt": "2026-09-22T14:00:02.000Z",
      "finishedAt": "2026-09-22T14:06:40.000Z",
      "error": null
    }
  ],
  "ranges": {
    "tip": 8700076,
    "presets": [
      {
        "blocks": 100,
        "fromBlock": 8699977,
        "toBlock": 8700076,
        "knownEvents": 4
      },
      {
        "blocks": 1000,
        "fromBlock": 8699077,
        "toBlock": 8700076,
        "knownEvents": 31
      },
      {
        "blocks": 10000,
        "fromBlock": 8690077,
        "toBlock": 8700076,
        "knownEvents": 305
      }
    ],
    "deadSinceFailing": {
      "since": "2026-09-22T13:10:00.000Z",
      "count": 3
    }
  }
}
```

### Get a replay's progress

`GET /api/webhooks/{id}/replays/{replay_id}` (self-hosted instances only)

Where one replay stands: how many events it queued, how many are done, and once nothing is left pending, how many delivered and how many gave up. Poll it while the replay drains.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |
| `replay_id` | string, required | Replay id, the `replayId` from the replay response (16 hex characters). |

#### Returns

One `WebhookReplayProgress` object.

#### Responses

- `200` The replay's progress
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id, or no replay with this id on it

#### Example

```bash
curl http://127.0.0.1:3800/api/webhooks/{id}/replays/{replay_id} \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "replayId": "a41f09c3d27e8b65",
  "webhookId": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
  "status": "done",
  "fromBlock": 8650000,
  "toBlock": 8700000,
  "total": 312,
  "processed": 312,
  "pending": 0,
  "delivered": 309,
  "gaveUp": 3,
  "startedAt": "2026-09-22T14:00:02.000Z",
  "finishedAt": "2026-09-22T14:06:40.000Z",
  "error": null
}
```

### Requeue dead events in bulk

`POST /api/webhooks/{id}/dead/requeue` (self-hosted instances only)

Moves dead events back to pending with their attempt counts reset, at live priority. With no body it requeues every dead event of the webhook; `replayId` narrows it to one replay and `since` to events that failed at or after that time. A replay that gets events back returns to running, so its progress tracks the retry.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Webhook id (UUID), as returned by create or list. |

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `replayId` | string | Only dead events created by this replay. |
| `since` | string · date-time | Only dead events that failed at or after this time. |

#### Responses

- `200` Events requeued
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No webhook with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X POST http://127.0.0.1:3800/api/webhooks/{id}/dead/requeue \
  -H "Authorization: Bearer $INSTANCE_TOKEN" \
  --json '{
  "replayId": "a41f09c3d27e8b65"
}'
```

```json
{
  "requeued": 14
}
```

## Insights

Ranked findings about your webhooks and subgraphs.

### List insights

`GET /api/insights` (self-hosted instances only)

Ranked findings about this instance's webhooks and subgraphs: losing events first, then about to fail, then costing money, then recently recovered. Dismissed and expected insights are left out.

#### Query parameters

| Name | Type | Description |
| --- | --- | --- |
| `resource` | string | Only one resource, as `<kind>:<id>`, such as `webhook:<id>` or `subgraph:<name>`. |

#### Responses

- `200` Insights in rank order
- `400` `resource` is not `<kind>:<id>` (`VALIDATION_ERROR`)
- `401` This bind requires the instance token, and it was missing or wrong

#### Example

```bash
curl http://127.0.0.1:3800/api/insights \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "data": [
    {
      "id": "9b0f6f1e-3c1a-4f55-8d64-0c2f7a5e1b30",
      "rule": "receiver_down",
      "severity": "losing",
      "impact": 214,
      "resource": {
        "kind": "webhook",
        "id": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
        "name": "sbtc-moves"
      },
      "line": "sbtc-moves has failed 12 deliveries in a row",
      "meta": "since 16:06 UTC",
      "lead": "Your receiver is down, not us.",
      "body": "hooks.example.dev returns HTTP 502 on every delivery since 16:06 UTC. 214 deliveries queued to retry.",
      "evidence": [
        {
          "kind": "shapes",
          "title": "What the receiver sent back",
          "shapes": [
            {
              "code": "502",
              "body": "Bad Gateway",
              "count": 12,
              "isNew": true
            }
          ]
        }
      ],
      "handoff": {
        "prompt": "My Secondlayer webhook \"sbtc-moves\" has been failing since 16:06 UTC. The receiver hooks.example.dev returns HTTP 502 on every delivery. Check the service behind hooks.example.dev. Then run sl webhooks doctor sbtc-moves.",
        "commands": [
          "sl webhooks doctor sbtc-moves"
        ]
      },
      "how": {
        "detector": "the newest 5 or more delivery attempts are 5xx or got no response",
        "facts": [
          [
            "consecutive failures",
            "12"
          ]
        ]
      },
      "createdAt": "2026-10-15T16:07:00.000Z",
      "resolvedAt": null
    }
  ]
}
```

### Dismiss an insight

`POST /api/insights/{id}/dismiss` (self-hosted instances only)

Hides this incident. If the same rule fires again after it clears, the new incident shows. Takes no body.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Insight id (UUID), as returned by the insights list. |

#### Responses

- `200` Dismissed
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No insight with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X POST http://127.0.0.1:3800/api/insights/{id}/dismiss \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "ok": true
}
```

### Undo dismissing an insight

`DELETE /api/insights/{id}/dismiss` (self-hosted instances only)

Shows a dismissed insight again.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Insight id (UUID), as returned by the insights list. |

#### Responses

- `200` Shown again
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No insight with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X DELETE http://127.0.0.1:3800/api/insights/{id}/dismiss \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "ok": true
}
```

### Snooze an insight until a time

`POST /api/insights/{id}/snooze` (self-hosted instances only)

Hides this insight until the time you give, at most 7 days ahead. It shows again then, or earlier if you remove the snooze.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Insight id (UUID), as returned by the insights list. |

#### Body

| Field | Type | Description |
| --- | --- | --- |
| `until` | string · date-time, required | When it shows again. Must be in the next 7 days. |

#### Responses

- `200` Snoozed
- `400` `until` is not a time in the next 7 days (`VALIDATION_ERROR`)
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No insight with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X POST http://127.0.0.1:3800/api/insights/{id}/snooze \
  -H "Authorization: Bearer $INSTANCE_TOKEN" \
  --json '{
  "until": "2026-10-19T09:00:00.000Z"
}'
```

```json
{
  "ok": true
}
```

### Remove an insight's snooze

`DELETE /api/insights/{id}/snooze` (self-hosted instances only)

Shows a snoozed insight again now.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Insight id (UUID), as returned by the insights list. |

#### Responses

- `200` Shown again
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No insight with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X DELETE http://127.0.0.1:3800/api/insights/{id}/snooze \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "ok": true
}
```

### Mark an insight expected

`POST /api/insights/{id}/expected` (self-hosted instances only)

Mutes this rule for this resource, including future incidents, until you remove the mute. Takes no body.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Insight id (UUID), as returned by the insights list. |

#### Responses

- `200` Muted
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No insight with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X POST http://127.0.0.1:3800/api/insights/{id}/expected \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "ok": true
}
```

### Unmark an insight as expected

`DELETE /api/insights/{id}/expected` (self-hosted instances only)

Removes the mute for this rule and resource, so it shows again whenever it fires.

#### Path parameters

| Name | Type | Description |
| --- | --- | --- |
| `id` | string, required | Insight id (UUID), as returned by the insights list. |

#### Responses

- `200` Unmuted
- `400` A parameter, filter, cursor or body field was refused; `code` says which
- `401` This bind requires the instance token, and it was missing or wrong
- `404` No insight with this id
- `415` Missing or non-JSON `Content-Type` (`UNSUPPORTED_MEDIA_TYPE`) — writes must send `Content-Type: application/json`

#### Example

```bash
curl -X DELETE http://127.0.0.1:3800/api/insights/{id}/expected \
  -H "Authorization: Bearer $INSTANCE_TOKEN"
```

```json
{
  "ok": true
}
```

## Objects

### The Webhook summary object

A webhook as it appears in the list.

| Field | Type | Description |
| --- | --- | --- |
| `id` | string · uuid | Webhook id. |
| `name` | string | Unique name on this instance. |
| `status` | enum | `active` delivers. `paused` holds new events in the outbox until resumed; the circuit breaker also pauses a webhook after 20 straight failures. `error` is reserved; nothing sets it today. Values: `active`, `paused`, `error`. |
| `kind` | enum | Always `chain`: webhooks fire on raw chain events matched by `triggers`. Values: `chain`. |
| `format` | enum | Payload and signing format. `standard-webhooks` signs with the webhook's secret; the others shape the body for that receiver. Values: `standard-webhooks`, `inngest`, `trigger`, `cloudflare`, `cloudevents`, `raw`. |
| `url` | string | Delivery target. |
| `lastDeliveryAt` | string · date-time · nullable | Last delivery attempt, successful or not. |
| `lastSuccessAt` | string · date-time · nullable | Last delivery the target answered with a 2xx. |
| `circuitOpenedAt` | string · date-time · nullable | When the circuit breaker paused this webhook. Cleared on resume. |
| `circuitFailures` | integer | Consecutive failed deliveries; a success resets it. The circuit opens at 20; resume resets it. |
| `managedBy` | string · nullable | Product that owns this webhook's name, derived from a reserved name prefix (for example `sentinel:` is Sentinel). When set, renaming returns 409 `WEBHOOK_NAME_MANAGED`. `null` for ordinary webhooks. |
| `createdAt` | string · date-time | When the webhook was created. |
| `updatedAt` | string · date-time | Last config or status change. |

```json
{
  "id": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
  "name": "sbtc-moves",
  "status": "active",
  "kind": "chain",
  "format": "standard-webhooks",
  "url": "https://example.com/webhooks/sbtc",
  "lastDeliveryAt": "2026-09-22T14:03:11.402Z",
  "lastSuccessAt": "2026-09-22T14:03:11.402Z",
  "circuitOpenedAt": null,
  "circuitFailures": 0,
  "managedBy": null,
  "createdAt": "2026-09-20T09:12:45.118Z",
  "updatedAt": "2026-09-20T09:12:45.118Z"
}
```

### The Webhook with its signing secret object

Returned by create and rotate-secret only. The one place the signing secret appears.

| Field | Type | Description |
| --- | --- | --- |
| `webhook` | Webhook |  |
| `signingSecret` | string | Plaintext signing secret, 64 hex characters. Shown once; store it server-side to verify deliveries. |

```json
{
  "webhook": {
    "id": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
    "name": "sbtc-moves",
    "status": "active",
    "kind": "chain",
    "format": "standard-webhooks",
    "url": "https://example.com/webhooks/sbtc",
    "lastDeliveryAt": "2026-09-22T14:03:11.402Z",
    "lastSuccessAt": "2026-09-22T14:03:11.402Z",
    "circuitOpenedAt": null,
    "circuitFailures": 0,
    "managedBy": null,
    "createdAt": "2026-09-20T09:12:45.118Z",
    "updatedAt": "2026-09-20T09:12:45.118Z",
    "triggers": [
      {
        "type": "ft_transfer",
        "assetIdentifier": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
        "minAmount": "100000"
      },
      {
        "type": "print_event",
        "contractId": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry"
      }
    ],
    "auth": {
      "type": "none",
      "headerNames": [],
      "hasSecret": false
    },
    "maxRetries": 7,
    "timeoutMs": 10000,
    "concurrency": 4,
    "lastError": null,
    "receiverCertExpiresAt": "2026-12-25T22:56:35.000Z",
    "warning": null
  },
  "signingSecret": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
}
```

### The Webhook object

A webhook's full config and delivery health. Never includes the signing secret.

| Field | Type | Description |
| --- | --- | --- |
| `id` | string · uuid | Webhook id. |
| `name` | string | Unique name on this instance. |
| `status` | enum | `active` delivers. `paused` holds new events in the outbox until resumed; the circuit breaker also pauses a webhook after 20 straight failures. `error` is reserved; nothing sets it today. Values: `active`, `paused`, `error`. |
| `kind` | enum | Always `chain`: webhooks fire on raw chain events matched by `triggers`. Values: `chain`. |
| `format` | enum | Payload and signing format. `standard-webhooks` signs with the webhook's secret; the others shape the body for that receiver. Values: `standard-webhooks`, `inngest`, `trigger`, `cloudflare`, `cloudevents`, `raw`. |
| `url` | string | Delivery target. |
| `lastDeliveryAt` | string · date-time · nullable | Last delivery attempt, successful or not. |
| `lastSuccessAt` | string · date-time · nullable | Last delivery the target answered with a 2xx. |
| `circuitOpenedAt` | string · date-time · nullable | When the circuit breaker paused this webhook. Cleared on resume. |
| `circuitFailures` | integer | Consecutive failed deliveries; a success resets it. The circuit opens at 20; resume resets it. |
| `managedBy` | string · nullable | Product that owns this webhook's name, derived from a reserved name prefix (for example `sentinel:` is Sentinel). When set, renaming returns 409 `WEBHOOK_NAME_MANAGED`. `null` for ordinary webhooks. |
| `createdAt` | string · date-time | When the webhook was created. |
| `updatedAt` | string · date-time | Last config or status change. |
| `triggers` | array of ChainTrigger | Chain triggers. |
| `auth` | object | Receiver credentials, redacted — set with `authConfig` on create or update, but never returned. `authConfig` is write-only. |
| `maxRetries` | integer · 0 to 100 | Attempts before an event moves to the dead-letter queue. Default 7. |
| `timeoutMs` | integer · 100 to 300000 | Per-attempt HTTP timeout in milliseconds. Default 10000. |
| `concurrency` | integer · 1 to 100 | Deliveries in flight at once for this webhook. Default 4. |
| `lastError` | string · nullable | Error from the last failed delivery. |
| `receiverCertExpiresAt` | string · date-time · nullable | When the receiver's TLS certificate expires, from a periodic probe. `null` for http receivers, unreachable hosts, and webhooks not probed yet. |
| `warning` | string · nullable | 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. |

```json
{
  "id": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
  "name": "sbtc-moves",
  "status": "active",
  "kind": "chain",
  "format": "standard-webhooks",
  "url": "https://example.com/webhooks/sbtc",
  "lastDeliveryAt": "2026-09-22T14:03:11.402Z",
  "lastSuccessAt": "2026-09-22T14:03:11.402Z",
  "circuitOpenedAt": null,
  "circuitFailures": 0,
  "managedBy": null,
  "createdAt": "2026-09-20T09:12:45.118Z",
  "updatedAt": "2026-09-20T09:12:45.118Z",
  "triggers": [
    {
      "type": "ft_transfer",
      "assetIdentifier": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
      "minAmount": "100000"
    },
    {
      "type": "print_event",
      "contractId": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry"
    }
  ],
  "auth": {
    "type": "none",
    "headerNames": [],
    "hasSecret": false
  },
  "maxRetries": 7,
  "timeoutMs": 10000,
  "concurrency": 4,
  "lastError": null,
  "receiverCertExpiresAt": "2026-12-25T22:56:35.000Z",
  "warning": null
}
```

### The Webhook test result object

Outcome of one test delivery.

| Field | Type | Description |
| --- | --- | --- |
| `ok` | boolean | `true` when the target answered 2xx. |
| `statusCode` | integer · nullable | HTTP status. `null` when no response came back. |
| `error` | string · nullable | Why the delivery failed. `null` on success. |
| `durationMs` | integer | Round trip in milliseconds. |
| `deliveryId` | string · uuid | The logged attempt, as it appears under deliveries. |

```json
{
  "ok": true,
  "statusCode": 200,
  "error": null,
  "durationMs": 138,
  "deliveryId": "c7a0e5d2-1f4b-4e8a-9d36-0b5f2a7c9e14"
}
```

### The Webhook delivery object

One HTTP attempt to deliver an event.

| Field | Type | Description |
| --- | --- | --- |
| `id` | string · uuid | Attempt id. |
| `seq` | integer | Position in the webhook's lifetime log. The newest attempt equals `total`. |
| `attempt` | integer | Which try this was for its event, from 1. |
| `statusCode` | integer · nullable | HTTP status. `null` when no response came back. |
| `blockHeight` | integer · nullable | Block of the delivered event. `null` for test deliveries and for events already compacted out of the outbox. |
| `blockTime` | string · date-time · nullable | When the delivered event's block was produced. `null` for test deliveries, events already compacted out of the outbox, and rows written before this column existed. |
| `errorMessage` | string · nullable | Why the attempt failed. `null` on success. |
| `durationMs` | integer · nullable | Round trip in milliseconds. |
| `responseBody` | string · nullable | Start of the target's response body. |
| `dispatchedAt` | string · date-time | When the attempt was sent. |

```json
{
  "id": "9b2e41c7-5d0a-4f3e-8c61-2a7f90d4e3b5",
  "seq": 4182,
  "attempt": 1,
  "statusCode": 200,
  "blockHeight": 8700076,
  "blockTime": "2026-09-22T14:02:58.000Z",
  "errorMessage": null,
  "durationMs": 142,
  "responseBody": "{\"received\":true}",
  "dispatchedAt": "2026-09-22T14:03:11.402Z"
}
```

### The Dead-lettered webhook event object

An event that used up its retries without a 2xx.

| Field | Type | Description |
| --- | --- | --- |
| `id` | string · uuid | Outbox id. Pass it as `outbox_id` to requeue. |
| `eventType` | string | Delivered `type`, e.g. `chain.ft_transfer.apply`. |
| `attempt` | integer | Attempts made before it was dead-lettered. |
| `blockHeight` | integer | Block the event came from. |
| `txId` | string · nullable | Transaction behind the event, when there is one. |
| `payload` | object | The event body that failed to deliver. |
| `failedAt` | string · date-time · nullable | When the last attempt failed. |
| `createdAt` | string · date-time | When the event was queued. |

```json
{
  "id": "5e8d3b17-a2c4-4f90-b6e1-7d0c29f4a853",
  "eventType": "chain.ft_transfer.apply",
  "attempt": 7,
  "blockHeight": 8700076,
  "txId": "0x839ef4720020f5aba341630e8965851f8537bc8fc99a856091093780c886a8be",
  "payload": {
    "action": "apply",
    "block_hash": "0xf9ff01e876f9276abc9f0fc79a4fbefc500bf2b93ed1effd0ef637ef84e10604",
    "block_height": 8700076,
    "block_time": "2026-05-01T11:59:48.000Z",
    "tx_id": "0x839ef4720020f5aba341630e8965851f8537bc8fc99a856091093780c886a8be",
    "canonical": true,
    "trigger": "ft_transfer",
    "event": {
      "tx_id": "0x839ef4720020f5aba341630e8965851f8537bc8fc99a856091093780c886a8be",
      "type": "ft_transfer_event",
      "event_index": 45,
      "data": {
        "amount": "764749",
        "sender": "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-stx-sbtc-v-1-bps-15",
        "recipient": "SP1GBRTAXY96ZDYRQY4GR0M9JTXVYD2FGFRGV60FJ",
        "asset_identifier": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token"
      }
    }
  },
  "failedAt": "2026-09-22T16:41:02.877Z",
  "createdAt": "2026-09-22T14:03:10.915Z"
}
```

### The Webhook replay object

What a replay queued.

| Field | Type | Description |
| --- | --- | --- |
| `replayId` | string | 16 hex characters, derived from webhook, range and `force`. The same inputs give the same id. |
| `enqueuedCount` | integer | Events queued. Lower than matches when part of the range was replayed before. |
| `scannedCount` | integer | Rows or events examined in the range. |

```json
{
  "replayId": "a41f09c3d27e8b65",
  "enqueuedCount": 312,
  "scannedCount": 4870
}
```

### The Webhook replay progress object

Live state of one replay. While it runs, `delivered` and `gaveUp` are `null`; they are set once no event is left pending.

| Field | Type | Description |
| --- | --- | --- |
| `replayId` | string | 16 hex characters. |
| `webhookId` | string | Webhook id (UUID). |
| `status` | enum | `scanning` while the range is read, `running` while events deliver, `done` when none are pending, `failed` if the scan broke. Values: `scanning`, `running`, `done`, `failed`. |
| `fromBlock` | integer | First block, inclusive. |
| `toBlock` | integer | Last block, inclusive. |
| `total` | integer | Events the replay queued. |
| `processed` | integer | Events delivered or given up on: `total` minus `pending`. |
| `pending` | integer | Events still waiting to be delivered or retried. |
| `delivered` | integer · nullable | Events delivered. `null` until the replay is done. |
| `gaveUp` | integer · nullable | Events that used up their retries. `null` until the replay is done. |
| `startedAt` | string | ISO 8601 time it started. |
| `finishedAt` | string · nullable | ISO 8601 time it finished. `null` while it runs. |
| `error` | string · nullable | Why the scan failed. `null` otherwise. |

```json
{
  "replayId": "a41f09c3d27e8b65",
  "webhookId": "3f1c2a9e-8b7d-4c21-9a55-6e0d4b2f7c18",
  "status": "done",
  "fromBlock": 8650000,
  "toBlock": 8700000,
  "total": 312,
  "processed": 312,
  "pending": 0,
  "delivered": 309,
  "gaveUp": 3,
  "startedAt": "2026-09-22T14:00:02.000Z",
  "finishedAt": "2026-09-22T14:06:40.000Z",
  "error": null
}
```

## Notes for agents

- Base: '$SECONDLAYER_API_URL/api/webhooks' with 'Authorization: Bearer $INSTANCE_TOKEN' (self-hosted write plane).
- Verify every delivery's signature with the webhook's signing secret; rotate it with 'POST /api/webhooks/{id}/rotate-secret'.
- Recover a down receiver: requeue dead events, or replay a block range.
- One endpoint as markdown: 'https://secondlayer.tools/docs/api-reference/<anchor>.md', e.g. create-webhook.

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