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

# Webhooks

> Receive signed lifecycle events without polling.

Webhooks notify your system when shelf state changes.

## How webhooks work

Register a webhook endpoint **once**, then submit as many documents as you need.
Every shelf lifecycle event is automatically delivered to all matching endpoints — you don't create a new webhook per document.

```text theme={"theme":{"light":"github-light","dark":"poimandres"}}
1. Setup (once)     POST /v1/webhooks → register your endpoint
2. Submit documents POST /v1/shelves  → upload as many as you need
3. Receive events   Your endpoint receives shelf.ready / shelf.failed
                    for every shelf — use the shelfPublicId in the
                    payload to correlate events to specific documents
```

<Warning>
  Do **not** create a webhook on every document submission. This accumulates
  endpoints, complicates secret management, and provides no benefit — a single
  endpoint already receives events for all your shelves.
</Warning>

## Subscribable events

| Event          | Description                        |
| -------------- | ---------------------------------- |
| `shelf.ready`  | Shelf processing completed         |
| `shelf.failed` | Shelf processing failed            |
| `shelf.review` | Shelf paused and awaiting approval |

`webhook.ping` is a test event triggered only by `POST /v1/webhooks/{webhookPublicId}/ping`.

## Register an endpoint

<CodeGroup>
  ```bash curl theme={"theme":{"light":"github-light","dark":"poimandres"}}
  curl -X POST https://api.shelv.dev/v1/webhooks \
    -H "Authorization: Bearer sk_your_api_key" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://your-app.com/webhooks/shelv",
      "events": ["shelf.ready", "shelf.failed", "shelf.review"]
    }'
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"poimandres"}}
  const res = await fetch("https://api.shelv.dev/v1/webhooks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SHELV_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url: "https://your-app.com/webhooks/shelv",
      events: ["shelf.ready", "shelf.failed", "shelf.review"],
    }),
  });
  const webhook = await res.json();
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"poimandres"}}
  import os
  import requests

  res = requests.post(
      "https://api.shelv.dev/v1/webhooks",
      headers={"Authorization": f"Bearer {os.environ['SHELV_API_KEY']}"},
      json={
          "url": "https://your-app.com/webhooks/shelv",
          "events": ["shelf.ready", "shelf.failed", "shelf.review"],
      },
  )
  webhook = res.json()
  ```
</CodeGroup>

The signing `secret` is returned on create and rotate.

## Endpoint URL requirements

* Must be an absolute URL without username/password credentials
* In production, must use `https://`
* In production, if a port is provided, it must be `443`
* Host must resolve
* Resolved IPs cannot be private, loopback, link-local, or special-purpose
* `localhost`, `*.localhost`, and `.local` are rejected in production
* In non-production, `http://localhost` and `http://*.localhost` are allowed

Invalid URL checks return `400` with `code=VALIDATION_ERROR` and details:

```json theme={"theme":{"light":"github-light","dark":"poimandres"}}
{
  "code": "VALIDATION_ERROR",
  "message": "Webhook URL must use https://",
  "details": {
    "field": "url",
    "reason": "https_required"
  }
}
```

## Delivery contract

* At-least-once delivery

* Use `webhook-id` as idempotency key

* Verify signature before processing

* `webhook-id`

* `webhook-timestamp`

* `webhook-signature` (`v1,<base64-hmac>`)

* `webhook-attempt`

* `webhook-endpoint-id`

Signed payload format:

```text theme={"theme":{"light":"github-light","dark":"poimandres"}}
{webhook-id}.{webhook-timestamp}.{raw_request_body}
```

## Signature verification example

```typescript theme={"theme":{"light":"github-light","dark":"poimandres"}}
import { createHmac, timingSafeEqual } from "node:crypto";

const MAX_AGE_SECONDS = 300;

function verify(
  body: string,
  id: string,
  timestamp: string,
  signature: string,
  secret: string,
): boolean {
  const [version, received] = signature.split(",");
  if (version !== "v1" || !received) return false;

  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - ts);
  if (age > MAX_AGE_SECONDS) return false;

  const signed = `${id}.${timestamp}.${body}`;
  const expected = createHmac("sha256", secret).update(signed).digest("base64");

  const expectedBuf = Buffer.from(expected);
  const receivedBuf = Buffer.from(received);
  if (expectedBuf.length !== receivedBuf.length) return false;

  return timingSafeEqual(expectedBuf, receivedBuf);
}
```

Handler checklist:

1. Read raw request body
2. Verify signature and enforce timestamp freshness window
3. Deduplicate by `webhook-id`
4. Process event

## Minimal operations

**List endpoints**

<CodeGroup>
  ```bash curl theme={"theme":{"light":"github-light","dark":"poimandres"}}
  curl https://api.shelv.dev/v1/webhooks \
    -H "Authorization: Bearer sk_your_api_key"
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"poimandres"}}
  const res = await fetch("https://api.shelv.dev/v1/webhooks", {
    headers: { Authorization: `Bearer ${process.env.SHELV_API_KEY}` },
  });
  const webhooks = await res.json();
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"poimandres"}}
  import os
  import requests

  res = requests.get(
      "https://api.shelv.dev/v1/webhooks",
      headers={"Authorization": f"Bearer {os.environ['SHELV_API_KEY']}"},
  )
  webhooks = res.json()
  ```
</CodeGroup>

**Rotate secret**

<CodeGroup>
  ```bash curl theme={"theme":{"light":"github-light","dark":"poimandres"}}
  curl -X POST https://api.shelv.dev/v1/webhooks/{webhookPublicId}/rotate-secret \
    -H "Authorization: Bearer sk_your_api_key"
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"poimandres"}}
  const res = await fetch(
    `https://api.shelv.dev/v1/webhooks/${webhookPublicId}/rotate-secret`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.SHELV_API_KEY}` },
    },
  );
  const rotated = await res.json();
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"poimandres"}}
  import os
  import requests

  res = requests.post(
      f"https://api.shelv.dev/v1/webhooks/{webhook_public_id}/rotate-secret",
      headers={"Authorization": f"Bearer {os.environ['SHELV_API_KEY']}"},
  )
  rotated = res.json()
  ```
</CodeGroup>

**Send test event**

<CodeGroup>
  ```bash curl theme={"theme":{"light":"github-light","dark":"poimandres"}}
  curl -X POST https://api.shelv.dev/v1/webhooks/{webhookPublicId}/ping \
    -H "Authorization: Bearer sk_your_api_key"
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"poimandres"}}
  const res = await fetch(
    `https://api.shelv.dev/v1/webhooks/${webhookPublicId}/ping`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.SHELV_API_KEY}` },
    },
  );
  const ping = await res.json();
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"poimandres"}}
  import os
  import requests

  res = requests.post(
      f"https://api.shelv.dev/v1/webhooks/{webhook_public_id}/ping",
      headers={"Authorization": f"Bearer {os.environ['SHELV_API_KEY']}"},
  )
  ping = res.json()
  ```
</CodeGroup>

For delivery history and redelivery endpoints, use the API Reference tab.
