Webhooks & Events
Webhooks & Events
DELPHOS delivers real-time notifications for scheduling events through a webhook system built on the transactional outbox pattern. Webhook payloads are signed with HMAC-SHA256 and delivered with automatic retries and dead-letter handling.
Available Event Types
| Event Type | Trigger |
|---|---|
appointment.created | New appointment booked |
appointment.confirmed | Appointment confirmed by staff |
appointment.cancelled | Appointment cancelled |
appointment.rescheduled | Appointment moved to a new time slot |
appointment.completed | Appointment finished successfully |
appointment.no_show | Patient did not appear |
appointment.checked_in | Patient arrived and checked in |
slot.released | Calendar slot became available |
Create a Webhook Subscription
Register a callback URL to receive scheduling events.
POST /v1/scheduling/webhooks| Field | Type | Required | Default | Description |
|---|---|---|---|---|
url | string | Yes | — | Target callback URL (HTTPS recommended) |
secret | string | Yes | — | HMAC-SHA256 shared secret (min 32 characters) |
event_types | array | Yes | — | Event types to subscribe to (at least one) |
description | string | No | null | Human-readable description |
metadata | object | No | {} | Additional metadata |
| Code | Meaning |
|---|---|
201 | Webhook subscription created |
400 | Secret too short or invalid event types |
422 | Validation error |
curl -X POST "https://your-instance.delphos.app/v1/scheduling/webhooks" \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/webhooks/scheduling", "secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", "event_types": [ "appointment.created", "appointment.confirmed", "appointment.cancelled", "appointment.completed" ], "description": "Production appointment notifications" }'import httpx
response = httpx.post( "https://your-instance.delphos.app/v1/scheduling/webhooks", headers={"x-api-key": "YOUR_API_KEY"}, json={ "url": "https://your-app.example.com/webhooks/scheduling", "secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", "event_types": [ "appointment.created", "appointment.confirmed", "appointment.cancelled", "appointment.completed", ], "description": "Production appointment notifications", },)
webhook = response.json()print(f"Webhook ID: {webhook['id']}")print(f"Subscribed to: {webhook['event_types']}")Manage Subscriptions
List subscriptions
GET /v1/scheduling/webhooks| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 20 | Records per page (max 100) |
offset | integer | 0 | Pagination offset |
Get a subscription
GET /v1/scheduling/webhooks/{webhook_id}Update a subscription
PUT /v1/scheduling/webhooks/{webhook_id}Only provided fields are updated. The shared secret cannot be changed through this endpoint.
| Field | Type | Required | Description |
|---|---|---|---|
url | string | No | New callback URL |
event_types | array | No | New event type subscriptions |
is_active | boolean | No | Enable or disable the subscription |
description | string | No | Updated description |
metadata | object | No | Updated metadata |
Deactivate a subscription
DELETE /v1/scheduling/webhooks/{webhook_id}Performs a soft-delete by setting is_active to false. The webhook record is
preserved, but pending deliveries for deactivated webhooks are skipped by the
dispatcher.
Webhook Payload
Every webhook delivery includes these HTTP headers:
| Header | Description |
|---|---|
X-Webhook-Signature | HMAC-SHA256 signature: sha256={hex_digest} |
X-Webhook-Timestamp | Unix timestamp (seconds) of delivery |
X-Webhook-Event-Id | Idempotency UUID for the event |
X-Webhook-Event-Type | Event type (e.g. appointment.confirmed) |
Content-Type | Always application/json |
User-Agent | DELPHOS-Webhooks/1.0 |
Payload schema
The JSON body contains the appointment data at the time of the event:
{ "id": "550e8400-e29b-41d4-a716-446655440001", "provider_profile_id": "550e8400-e29b-41d4-a716-446655440002", "patient_id": "550e8400-e29b-41d4-a716-446655440003", "appointment_type_id": "550e8400-e29b-41d4-a716-446655440004", "location_id": "550e8400-e29b-41d4-a716-446655440005", "start_time": "2026-05-15T09:00:00+00:00", "end_time": "2026-05-15T09:30:00+00:00", "status": "confirmed", "source": "web", "version": 2}| Field | Type | Description |
|---|---|---|
id | UUID | Appointment UUID |
provider_profile_id | UUID | Provider who owns the appointment |
patient_id | UUID | Patient UUID |
appointment_type_id | UUID | Appointment type UUID |
location_id | UUID | Location UUID |
start_time | datetime | Appointment start (ISO 8601) |
end_time | datetime | Appointment end (ISO 8601) |
status | string | Current appointment status |
source | string | Booking channel |
version | integer | Optimistic concurrency version |
Signature Verification
DELPHOS signs every payload with HMAC-SHA256 using your shared secret. The
signature message format is {timestamp}.{json_payload}, which binds the
timestamp to the payload to prevent replay attacks.
- Extract
X-Webhook-TimestampandX-Webhook-Signaturefrom request headers. - Construct the signed message:
{timestamp}.{raw_body}. - Compute HMAC-SHA256 using your shared secret.
- Compare using constant-time comparison to prevent timing attacks.
- Optionally reject events where the timestamp is older than 5 minutes.
import hashlibimport hmacimport time
def verify_webhook( raw_body: bytes, secret: str, signature_header: str, timestamp_header: str, tolerance_seconds: int = 300,) -> bool: """Verify a DELPHOS webhook signature.
Args: raw_body: Raw request body bytes. secret: Your shared secret. signature_header: Value of X-Webhook-Signature header. timestamp_header: Value of X-Webhook-Timestamp header. tolerance_seconds: Max age of event (default 5 minutes).
Returns: True if the signature is valid and timestamp is fresh. """ timestamp = int(timestamp_header)
# Reject stale events if abs(time.time() - timestamp) > tolerance_seconds: return False
# Reconstruct the signed message message = f"{timestamp}.{raw_body.decode('utf-8')}" expected = "sha256=" + hmac.new( secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256, ).hexdigest()
return hmac.compare_digest(expected, signature_header)const crypto = require("crypto");
function verifyWebhook(rawBody, secret, signatureHeader, timestampHeader, toleranceSeconds = 300) { const timestamp = parseInt(timestampHeader, 10);
// Reject stale events if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) { return false; }
const message = `${timestamp}.${rawBody}`; const expected = "sha256=" + crypto .createHmac("sha256", secret) .update(message) .digest("hex");
return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signatureHeader), );}Retry Policy
Failed deliveries are retried with increasing delays:
| Attempt | Delay | Total elapsed |
|---|---|---|
| 1st retry | 5 seconds | ~5s |
| 2nd retry | 30 seconds | ~35s |
| 3rd retry | 5 minutes | ~5m 35s |
After all retry attempts are exhausted, the delivery is moved to the dead-letter queue. The default maximum is 3 attempts.
A delivery is considered successful when your endpoint returns an HTTP
status code in the 2xx range. Any other response code, connection error,
or timeout triggers a retry.
| Delivery Status | Meaning |
|---|---|
pending | Awaiting initial dispatch |
success | Delivered successfully (2xx response) |
failed | Attempt failed, retry scheduled |
dead_letter | All retries exhausted |
Test Events
Send a test event to verify webhook connectivity before going live.
POST /v1/scheduling/webhooks/{webhook_id}/testCreates a webhook.test delivery that the background dispatcher picks up and
delivers to your callback URL.
| Code | Meaning |
|---|---|
201 | Test event queued |
404 | Webhook not found or inactive |
Response:
{ "delivery_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "event_type": "webhook.test", "status": "pending"}curl -X POST "https://your-instance.delphos.app/v1/scheduling/webhooks/WEBHOOK_ID/test" \ -H "x-api-key: YOUR_API_KEY"import httpx
response = httpx.post( f"https://your-instance.delphos.app/v1/scheduling/webhooks/{webhook_id}/test", headers={"x-api-key": "YOUR_API_KEY"},)
result = response.json()print(f"Test delivery ID: {result['delivery_id']}")Delivery History
Inspect delivery attempts for debugging.
GET /v1/scheduling/webhooks/{webhook_id}/deliveries| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 20 | Records per page (max 100) |
offset | integer | 0 | Pagination offset |
Each delivery record includes:
| Field | Type | Description |
|---|---|---|
id | UUID | Delivery UUID |
webhook_id | UUID | Parent webhook subscription UUID |
event_id | UUID | Idempotency event UUID |
event_type | string | Event type |
payload | object | Event payload |
status | string | pending, success, failed, dead_letter |
attempt_count | integer | Number of delivery attempts |
max_attempts | integer | Maximum allowed attempts |
next_retry_at | datetime | Next retry timestamp (if retrying) |
last_response_code | integer | Last HTTP status code received |
last_response_body | string | Last HTTP response body (truncated to 1000 chars) |
last_error | string | Last error message |
delivered_at | datetime | Successful delivery timestamp |
outbox_processed | boolean | Whether the outbox entry has been dispatched |
created_at | datetime | Delivery creation timestamp (UTC) |
curl "https://your-instance.delphos.app/v1/scheduling/webhooks/WEBHOOK_ID/deliveries?limit=10" \ -H "x-api-key: YOUR_API_KEY"import httpx
response = httpx.get( f"https://your-instance.delphos.app/v1/scheduling/webhooks/{webhook_id}/deliveries", headers={"x-api-key": "YOUR_API_KEY"}, params={"limit": 10},)
data = response.json()for delivery in data["deliveries"]: print(f"{delivery['event_type']}: {delivery['status']} " f"(attempts: {delivery['attempt_count']}/{delivery['max_attempts']})")Dead-Letter Queue
Deliveries that exhaust all retry attempts are moved to the dead-letter queue for manual inspection.
GET /v1/scheduling/webhooks/dead-lettersReview dead letters to identify recurring connectivity issues, incorrect callback URLs, or endpoints that consistently return errors.
Architecture
DELPHOS uses a transactional outbox pattern backed by a Redis retry queue:
+----------------+ +------------------+ +------------+| API Action |---->| Outbox Table |---->| Dispatcher || (e.g. confirm) | | (same TX as | | (1s poll) || | | appointment) | | |+----------------+ +------------------+ +------+-----+ | +--------v--------+ | HTTP POST to | | your endpoint | +--------+--------+ | +-------------v------------+ | 2xx? -> success | | else -> Redis retry ZSET | | (5s, 30s, 5m) | | exhausted -> dead_letter | +--------------------------+Guarantees:
- At-least-once delivery: Events are written to the outbox in the same database transaction as the appointment change. The dispatcher polls the outbox every second.
- No redirects: The dispatcher does not follow HTTP redirects to prevent redirect-based SSRF.
- Response size limit: Response bodies larger than 100KB are truncated in delivery logs.
Error Handling
| Code | When |
|---|---|
400 | Secret too short or invalid event types |
404 | Webhook not found or inactive |
422 | Validation error |
500 | Internal server error |
All error responses follow the standard format:
{ "detail": "Webhook a1b2c3d4-... not found"}Related Articles
- Booking an Appointment — Trigger webhook events by booking
- Calendar Management — Schedule configuration
- API Explorer — Browse all DELPHOS endpoints