Guides

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

MethodEndpointPurpose
POST/v1/baskets/{id}/webhooksCreate a webhook subscription
DELETE/v1/baskets/{id}/webhooks/{whId}Delete a webhook subscription
POST/v1/baskets/{id}/webhooks/{whId}/replayRe-enqueue terminal failed deliveries

Create requests must supply an HTTPS URL.

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.

{
  "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.

HeaderMeaning
X-Philidor-SignatureHMAC signature over timestamp.body
X-Philidor-Event-IdStable event id used for dedupe
X-Philidor-Delivery-AttemptCurrent delivery attempt number
User-Agentphilidor-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 pathDelay before next attempt
1 to 210 seconds
2 to 31 minute
3 to 45 minutes
4 to 530 minutes
5 to 62 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.

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"
{
  "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.

import { createHmac, timingSafeEqual } from 'node:crypto';

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.

On this page

Raw