Webhooks
Signed push delivery for published events, retractions, rating changes, security events and basket lifecycle; retry, dead-letter replay, circuit breaker, ownership and rotation.
Webhooks push signed JSON to an HTTPS endpoint you control. There are two families:
| Family | Events | Manage at | Plan |
|---|---|---|---|
| Generic | event.published, event.retracted, rating.changed, security_event.published | /v1/webhooks | Decisioning and Embedded |
| Basket | basket.rebalanced, basket.version.deprecated, basket.version.retired | /v1/baskets/{id}/webhooks | Decisioning and Embedded |
A subscription belongs to your organisation, not to the key that created it: every key of the organisation can list, delete, replay and re-enable it, and it survives key rotation. Revoking the delivery credential disables the subscription (disabled_reason: key_revoked). Any live key of the same organisation can re-enable it, adopting that key for delivery without changing its ID, signing secret or creating-key audit record.
Endpoints
| Method | Endpoint | Purpose |
|---|---|---|
POST | /v1/webhooks | Create a subscription (url, families[], optional description) |
GET | /v1/webhooks | List your organisation's subscriptions |
GET | /v1/webhooks/{id} | One subscription |
DELETE | /v1/webhooks/{id} | Delete |
POST | /v1/webhooks/{id}/test | Enqueue one webhook.test delivery |
GET | /v1/webhooks/{id}/deliveries | Recent deliveries (newest first, up to 200) |
POST | /v1/webhooks/{id}/replay | Re-enqueue failed_terminal deliveries in a window |
POST | /v1/webhooks/{id}/enable | Re-activate after the circuit breaker paused or disabled it |
POST | /v1/baskets/{id}/webhooks | Create a basket subscription |
GET | /v1/baskets/webhooks | List your organisation’s subscriptions across all baskets |
GET | /v1/baskets/{id}/webhooks | List your organisation's subscriptions on a basket |
DELETE | /v1/baskets/{id}/webhooks/{whId} | Delete |
POST | /v1/baskets/{id}/webhooks/{whId}/replay | Replay terminal failures |
POST | /v1/baskets/{id}/webhooks/{whId}/enable | Re-activate |
Create requests must supply an HTTPS URL.
curl -X POST https://api.philidor.io/v1/webhooks \
-H "Authorization: Bearer pk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/philidor-webhooks","families":["event.published","event.retracted","rating.changed"],"description":"incident intake"}'The creation response returns the signing secret once.
{
"data": {
"id": 12,
"url": "https://example.com/philidor-webhooks",
"families": ["event.published", "event.retracted", "rating.changed"],
"description": "incident intake",
"status": "active",
"disabled_reason": null,
"failure_count": 0,
"paused_until": null,
"api_key_id": "0b0c8c3e-…",
"created_at": "2026-09-12T10:00:00.000Z",
"updated_at": "2026-09-12T10:00:00.000Z",
"secret": "…"
}
}Send yourself a test delivery, then read the log:
curl -X POST https://api.philidor.io/v1/webhooks/12/test -H "Authorization: Bearer pk_live_your_key_here"
curl "https://api.philidor.io/v1/webhooks/12/deliveries?limit=5" -H "Authorization: Bearer pk_live_your_key_here"Payloads
Every body has the same envelope. data is the row exactly as the public API serves it, so one parser covers the feed and the push.
{
"event_id": "5f3d9c2a-…",
"event_type": "event.published",
"occurred_at": "2026-09-12T10:00:00.123456Z",
"data": { "...": "the event as GET /v1/events/{id}" }
}event_type | data | When |
|---|---|---|
event.published | the event as GET /v1/events/{id} | an event becomes public (insert, or a draft published) |
event.retracted | the same, with retracted_at and retracted_reason set | a public event is retracted or demoted |
rating.changed | the same; source is rating-change | a vault's published score or tier moves |
security_event.published | the row as GET /v1/security-events | a security event enters the broad feed |
webhook.test | { "subscription_id": 12 } | you asked for a test delivery |
basket.rebalanced, basket.version.* | the basket event body | basket lifecycle (basket family only) |
occurred_at is when the operation happened, microsecond precision, UTC. Delivery is at-least-once: dedupe on event_id. Events whose chain is withheld from the public API are not pushed.
Delivery
The dispatcher enqueues one pending delivery per subscription whose families include the event's, and POSTs them with bounded concurrency: one partner's slow endpoint never delays another's. Each 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
A subscription is paused for one hour when the last three attempts within fifteen minutes all failed (retrying attempts count, so a dead endpoint pauses within minutes), or when at least ten deliveries reached a final outcome in the last 24 hours with a failure ratio of 50% or more. Paused subscriptions resume automatically after the cooldown. Three consecutive pauses without a successful delivery in between disable the subscription (status: disabled, disabled_reason: breaker); POST …/enable re-activates it and resets the chain once your endpoint is fixed.
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.
Key and Secret Rotation
Subscriptions survive API key rotation: mint a second key, cut your integration over, then revoke the first. Every key of your organisation manages the same subscriptions. Before revoking the first key, call POST …/enable for each retained subscription using the second key. This adopts the second key as the delivery credential while preserving the creating key for audit. Then revoke the first key. If you revoke first, the subscriptions stop in the same transaction (disabled_reason: key_revoked); list and re-enable them using the second key to resume. In either order the subscription IDs and signing secrets survive. Already-started HTTP requests cannot be recalled; pickup and the final pre-send guard reject inactive credentials.
To rotate a webhook secret, create a replacement subscription, configure the receiver to accept both secrets during the change window, then delete the old subscription. The signature parser accepts multiple v1 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.