Guides

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:

FamilyEventsManage atPlan
Genericevent.published, event.retracted, rating.changed, security_event.published/v1/webhooksDecisioning and Embedded
Basketbasket.rebalanced, basket.version.deprecated, basket.version.retired/v1/baskets/{id}/webhooksDecisioning 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

MethodEndpointPurpose
POST/v1/webhooksCreate a subscription (url, families[], optional description)
GET/v1/webhooksList your organisation's subscriptions
GET/v1/webhooks/{id}One subscription
DELETE/v1/webhooks/{id}Delete
POST/v1/webhooks/{id}/testEnqueue one webhook.test delivery
GET/v1/webhooks/{id}/deliveriesRecent deliveries (newest first, up to 200)
POST/v1/webhooks/{id}/replayRe-enqueue failed_terminal deliveries in a window
POST/v1/webhooks/{id}/enableRe-activate after the circuit breaker paused or disabled it
POST/v1/baskets/{id}/webhooksCreate a basket subscription
GET/v1/baskets/webhooksList your organisation’s subscriptions across all baskets
GET/v1/baskets/{id}/webhooksList your organisation's subscriptions on a basket
DELETE/v1/baskets/{id}/webhooks/{whId}Delete
POST/v1/baskets/{id}/webhooks/{whId}/replayReplay terminal failures
POST/v1/baskets/{id}/webhooks/{whId}/enableRe-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_typedataWhen
event.publishedthe event as GET /v1/events/{id}an event becomes public (insert, or a draft published)
event.retractedthe same, with retracted_at and retracted_reason seta public event is retracted or demoted
rating.changedthe same; source is rating-changea vault's published score or tier moves
security_event.publishedthe row as GET /v1/security-eventsa security event enters the broad feed
webhook.test{ "subscription_id": 12 }you asked for a test delivery
basket.rebalanced, basket.version.*the basket event bodybasket 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.

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

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.

On this page

Raw