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