# Webhooks

> Basket webhook delivery, retry, dead-letter replay, URL validation, signatures, and version retirement notices.

Basket webhooks deliver signed notifications for reference basket events. Webhook write routes require an issued growth or enterprise key.

## Endpoints

| Method   | Endpoint                                  | Purpose                               |
| -------- | ----------------------------------------- | ------------------------------------- |
| `POST`   | `/v1/baskets/{id}/webhooks`               | Create a webhook subscription         |
| `DELETE` | `/v1/baskets/{id}/webhooks/{whId}`        | Delete a webhook subscription         |
| `POST`   | `/v1/baskets/{id}/webhooks/{whId}/replay` | Re-enqueue terminal failed deliveries |

Create requests must supply an HTTPS URL.

```bash
curl -X POST https://api.philidor.io/v1/baskets/rwa-tbill-conservative/webhooks \
  -H "Authorization: Bearer pk_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/philidor-webhooks"}'
```

The creation response returns the signing secret once.

```json
{
  "data": {
    "id": 7,
    "basket_id": "rwa-tbill-conservative",
    "url": "https://example.com/philidor-webhooks",
    "secret": "whsec_...",
    "status": "active",
    "created_at": "2026-07-01T00:00:00Z"
  }
}
```

## Delivery

The dispatcher enqueues one pending delivery per active subscription when a basket event is emitted. Each HTTP request is a `POST` with a JSON body and these headers.

| Header                        | Meaning                              |
| ----------------------------- | ------------------------------------ |
| `X-Philidor-Signature`        | HMAC signature over `timestamp.body` |
| `X-Philidor-Event-Id`         | Stable event id used for dedupe      |
| `X-Philidor-Delivery-Attempt` | Current delivery attempt number      |
| `User-Agent`                  | `philidor-webhooks/1.0`              |

Event ids are stable across retries. Receivers should dedupe on `X-Philidor-Event-Id`.

## Retry and DLQ

Any non-2xx response, timeout, blocked URL, or delivery error counts as a failed attempt. The delivery timeout is 10 seconds.

| Attempt path | Delay before next attempt |
| ------------ | ------------------------- |
| 1 to 2       | 10 seconds                |
| 2 to 3       | 1 minute                  |
| 3 to 4       | 5 minutes                 |
| 4 to 5       | 30 minutes                |
| 5 to 6       | 2 hours                   |

There are 6 total attempts. A delivery that still fails on the final attempt moves to `failed_terminal`.

Replay re-enqueues terminal failures for a subscription. The default replay window is the last 24 hours.

```bash
curl -X POST \
  "https://api.philidor.io/v1/baskets/rwa-tbill-conservative/webhooks/7/replay?from=2026-07-01T00:00:00Z&to=2026-07-02T00:00:00Z" \
  -H "Authorization: Bearer pk_live_your_key_here"
```

```json
{
  "data": {
    "enqueued": 3
  }
}
```

## Circuit Breaker

The dispatcher evaluates a 24-hour failure ratio after attempts. When the sample is large enough and the failure ratio reaches the breaker threshold, the subscription can be paused. Repeated auto-pauses can disable the subscription.

Paused subscriptions are swept back to active after the cooldown unless they have reached the disable threshold.

## URL Validation

Webhook URLs must use HTTPS. The backend rejects localhost, private address space, reserved address space, non-public hostnames, and hostnames that do not resolve.

URL checks run at creation and again before every delivery attempt. This protects against DNS changes after subscription creation.

## Signature Verification

Philidor signs the exact string `timestamp.body` with HMAC-SHA-256. The timestamp is the `t` value from the signature header.

```typescript

function verifyPhilidorWebhook(secret: string, rawBody: string, header: string): boolean {
  const parts = header.split(',').map((part) => part.trim().split('='));
  const timestamp = parts.find(([key]) => key === 't')?.[1];
  const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value);

  if (!timestamp || signatures.length === 0) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 300) return false;

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`, 'utf8')
    .digest('hex');

  return signatures.some((candidate) => {
    const a = Buffer.from(expected, 'hex');
    const b = Buffer.from(candidate, 'hex');
    return a.length === b.length && timingSafeEqual(a, b);
  });
}
```

Use the raw body bytes as received by your HTTP server. Do not verify a reserialized JSON object.

## Secret Rotation

The current public API exposes create and delete operations. To rotate a secret, create a replacement subscription, configure the receiver to accept both old and new secrets during the change window, then delete the old subscription.

The signature parser accepts multiple `v1` signature segments, so receivers can also support a local rotation window.

## Version Retirement Notices

Subscriptions can pin to a basket version with `pinned_version_id`. Version lifecycle events are routed to pinned subscriptions.

The backend has lifecycle channels for `basket_version_deprecated` and `basket_version_retired`. Delivered payloads use event types such as `basket.version.deprecated` and `basket.version.retired`.

When a pinned basket version is retired, the immutable version URL returns `410 VERSION_RETIRED` with a successor pointer. See [Versioning](/docs/reference/versioning).