Webhooks
Register HTTPS endpoints to receive real-time notifications when agents, events, or proposals change. Chronary signs every delivery with HMAC-SHA256 and retries failed deliveries with a fixed backoff schedule.
Create a webhook
Section titled “Create a webhook”POST /v1/webhooksRequest body
Section titled “Request body”| Field | Type | Required | Description |
|-------|------|----------|-------------|
| url | string | Yes | HTTPS URL to receive webhook deliveries |
| events | string[] | Yes | At least one event type (see Event catalog below) |
Event types
Section titled “Event types”event.created— event created withstatus=confirmedortentativeevent.updatedevent.deletedevent.started— fires at the scheduledstart_timeof a confirmed eventevent.ended— fires at the scheduledend_timeof a confirmed eventevent.reminder— fires ahead of a confirmed event’sstart_time, one per configured reminder offsetevent.hold_created— tentative hold placedevent.hold_expired— hold auto-expired by TTL or pre-empted by higher-priority holdevent.hold_released— hold manually released via/releaseevent.hold_confirmed— hold promoted to confirmed event via/confirmagent.createdagent.updatedproposal.createdproposal.respondedproposal.confirmedproposal.expiredproposal.cancelled
Example
Section titled “Example”curl -X POST https://api.chronary.ai/v1/webhooks \ -H "Authorization: Bearer chr_sk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhooks/chronary", "events": ["event.created", "event.updated", "event.deleted"] }'from chronary import Chronary
client = Chronary()
webhook = client.webhooks.create( url="https://your-server.com/webhooks/chronary", events=["event.created", "event.updated", "event.deleted"],)print(webhook.secret) # Save this for HMAC verificationResponse 201 Created
Section titled “Response 201 Created”{ "id": "whk_01H9X4A1B2C3D4E5F6", "url": "https://your-server.com/webhooks/chronary", "events": ["event.created", "event.updated", "event.deleted"], "secret": "whsec_abc123...", "active": true, "created_at": "2026-04-04T12:00:00Z"}The secret is only returned on creation — save it for HMAC signature verification.
List webhooks
Section titled “List webhooks”GET /v1/webhooksQuery parameters
Section titled “Query parameters”| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| limit | integer | 20 | 1–100 |
| offset | integer | 0 | Pagination offset |
Example
Section titled “Example”curl "https://api.chronary.ai/v1/webhooks" \ -H "Authorization: Bearer chr_sk_your_key_here"for webhook in client.webhooks.list(): print(f"{webhook.id}: {webhook.url} (active={webhook.active})")Get a webhook
Section titled “Get a webhook”GET /v1/webhooks/:idErrors
Section titled “Errors”| Status | Type | Cause |
|--------|------|-------|
| 404 | not_found | Webhook not found |
Update a webhook
Section titled “Update a webhook”PATCH /v1/webhooks/:idRequest body
Section titled “Request body”| Field | Type | Description |
|-------|------|-------------|
| url | string | New delivery URL |
| events | string[] | New event type list |
| active | boolean | Enable/disable deliveries |
Example
Section titled “Example”curl -X PATCH https://api.chronary.ai/v1/webhooks/whk_01H9X4A1B2C3D4E5F6 \ -H "Authorization: Bearer chr_sk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "active": false }'updated = client.webhooks.update("whk_01H9X4A1B2C3D4E5F6", active=False)Errors
Section titled “Errors”| Status | Type | Cause |
|--------|------|-------|
| 404 | not_found | Webhook not found |
Delete a webhook
Section titled “Delete a webhook”DELETE /v1/webhooks/:idReturns 204 No Content.
Errors
Section titled “Errors”| Status | Type | Cause |
|--------|------|-------|
| 404 | not_found | Webhook not found |
List webhook deliveries
Section titled “List webhook deliveries”Inspect delivery history for a subscription — useful for diagnosing failures, confirming events were received, or auditing retry counts.
GET /v1/webhooks/:id/deliveriesAuth: org-level API key only. Agent-scoped keys (chr_ak_*) return 403.
Query parameters
Section titled “Query parameters”| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| limit | integer | 20 | Results per page (1–100) |
| offset | integer | 0 | Pagination offset |
| status | string | — | Filter by status: pending, delivered, or failed |
| include_payload | boolean | false | Include the full event payload in each delivery record. Pass true or false — do not omit the value, as some query string parsers treat the bare key as truthy. |
Response
Section titled “Response”{ "data": [ { "id": "whd_01H9X4M2P5R8T6V0", "subscription_id": "whk_01H9X4M2P5R8T6V0", "event_type": "event.created", "status": "failed", "attempts": 3, "last_attempt_at": "2026-04-18T10:00:00.000Z", "next_retry_at": null, "created_at": "2026-04-18T09:55:00.000Z" } ], "total": 42, "limit": 20, "offset": 0, "stats": { "pending": 2, "delivered": 35, "failed": 5 }}payload is absent unless include_payload=true is specified.
stats reflects all-time counts for this webhook — not filtered by the status query param. Use it to render a success-rate summary without additional requests.
Errors
Section titled “Errors”| Status | Type | Cause |
|--------|------|-------|
| 400 | validation_error | Invalid status value |
| 403 | forbidden | Called with an agent-scoped key |
| 404 | not_found | Webhook not found or not owned by caller |
Delivery envelope
Section titled “Delivery envelope”Every webhook delivery is an HTTP POST to your configured URL with a JSON body and three signature-related headers. The JSON body is the event-specific payload (see Event catalog) — the envelope metadata lives in the headers.
Request headers
Section titled “Request headers”| Header | Description |
|--------|-------------|
| Content-Type | Always application/json |
| X-Signature | HMAC-SHA256 signature, formatted as sha256=<hex>. Computed over `${timestamp}.${body}` using your webhook secret. |
| X-Timestamp | Unix epoch seconds (decimal string, e.g. 1745784205) at the moment the delivery was signed. Included in the signed string to prevent replay. |
| X-Delivery-Id | Unique delivery ID (whd_...). Idempotency key — the same logical event can be retried with the same X-Delivery-Id. |
Request body
Section titled “Request body”The body is the raw event payload as JSON. The body does not include the event type, delivery ID, or timestamp — those travel in headers, not the body. Your handler should read X-Delivery-Id for idempotency and infer the event type from the subscription configuration or from the payload shape.
Response expectations
Section titled “Response expectations”Your endpoint must respond with any 2xx status within 10 seconds to be considered delivered. Any non-2xx status, timeout, or network error is retried.
Retry schedule
Section titled “Retry schedule”Failed deliveries are retried at fixed offsets: immediate → +1 min → +5 min → +30 min. After 4 attempts, the delivery is marked failed. A subscription that accumulates 50 cumulative failures is automatically disabled (active: false).
Signature verification
Section titled “Signature verification”Chronary signs `${X-Timestamp}.${raw_body}` with your webhook secret using HMAC-SHA256 and sends the hex digest in X-Signature as sha256=<hex>. Verify the signature with a constant-time comparison before trusting the payload.
import crypto from 'node:crypto';
function verifyChronarySignature(secret, timestamp, body, signatureHeader) { const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(`${timestamp}.${body}`) .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(signatureHeader); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b);}import hmacimport hashlib
def verify_chronary_signature(secret: str, timestamp: str, body: bytes, signature_header: str) -> bool: expected = "sha256=" + hmac.new( secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature_header)Reject the request if the signature does not match or if X-Timestamp is older than ~5 minutes (to mitigate replay attacks).
Event catalog
Section titled “Event catalog”Chronary emits 17 event types across three domains: agents, events (including tentative holds), and scheduling proposals. Subscribe to any subset on a per-webhook basis via the events array.
| Event | When it fires | Key payload fields |
|-------|---------------|--------------------|
| Agent events | | |
| agent.created | An agent is created via POST /v1/agents | agent |
| agent.updated | An agent is updated via PATCH /v1/agents/:id | agent |
| Event lifecycle | | |
| event.created | A non-hold event is created via POST /v1/calendars/:cal_id/events | calendar_id, event |
| event.updated | An event is updated via PATCH /v1/calendars/:cal_id/events/:id | calendar_id, event |
| event.deleted | An event is deleted via DELETE /v1/calendars/:cal_id/events/:id | calendar_id, event_id |
| Event execution (fired by lifecycle scheduler) | | |
| event.started | A confirmed event’s start_time is reached | event_id, calendar_id, title, start_time, end_time |
| event.ended | A confirmed event’s end_time is reached | event_id, calendar_id, title, start_time, end_time |
| event.reminder | A confirmed event’s start_time minus a configured reminder offset is reached | event_id, calendar_id, title, start_time, end_time, reminder_minutes |
| Temporal holds | | |
| event.hold_created | A tentative hold is created via POST /v1/calendars/:cal_id/events with status=hold | calendar_id, event |
| event.hold_expired | A hold’s TTL elapses, or a higher-priority hold pre-empts it | calendar_id, event_id |
| event.hold_released | A hold is manually released via PUT /v1/events/:id/release | calendar_id, event_id |
| event.hold_confirmed | A hold is promoted to a confirmed event via PUT /v1/events/:id/confirm | calendar_id, event |
| Proposal events | | |
| proposal.created | A proposal is created via POST /v1/scheduling/proposals | proposal |
| proposal.responded | A participant posts a response via POST /v1/scheduling/proposals/:id/respond | proposal_id, agent_id, response |
| proposal.confirmed | A proposal is resolved to a winning slot and a calendar event is created | proposal_id, resolved_slot, created_event_id |
| proposal.cancelled | The organizer cancels the proposal, or all participants decline | proposal_id, reason |
| proposal.expired | The proposal’s expires_at is reached while still pending | proposal_id |
agent.created
Section titled “agent.created”Fires when a new agent is created. Payload wraps the raw agent row under an agent key. Field names are camelCase (orgId, createdAt, updatedAt) — this payload is the raw database row, not the snake_case POST /v1/agents response body.
{ "agent": { "id": "agt_01H9X4A1B2C3D4E5F6", "orgId": "5a0f6b9a-6a3c-4a8f-9c3d-1e2f3a4b5c6d", "name": "Booking Bot", "type": "ai", "description": "Handles inbound booking requests.", "status": "active", "metadata": { "team": "ops" }, "createdAt": "2026-04-16T12:00:00.000Z", "updatedAt": "2026-04-16T12:00:00.000Z" }}agent.updated
Section titled “agent.updated”Fires when an agent is patched. Payload contains the fully-updated agent row. Same camelCase shape as agent.created.
{ "agent": { "id": "agt_01H9X4A1B2C3D4E5F6", "orgId": "5a0f6b9a-6a3c-4a8f-9c3d-1e2f3a4b5c6d", "name": "Booking Bot", "type": "ai", "description": "Handles inbound booking requests for EMEA.", "status": "active", "metadata": { "team": "ops", "region": "emea" }, "createdAt": "2026-04-16T12:00:00.000Z", "updatedAt": "2026-04-16T13:15:00.000Z" }}event.created
Section titled “event.created”Fires when a calendar event is created. The payload includes the event row and its parent calendar_id for routing convenience.
{ "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "event": { "id": "evt_01H9X4A1B2C3D4E5F6", "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "title": "Strategy sync with Acme Corp", "start_time": "2026-04-17T14:00:00Z", "end_time": "2026-04-17T14:30:00Z", "description": "Quarterly strategy alignment", "all_day": false, "status": "confirmed", "source": "internal", "metadata": { "deal_id": "deal_789" }, "created_at": "2026-04-16T12:00:00Z", "updated_at": "2026-04-16T12:00:00Z" }}event.updated
Section titled “event.updated”Fires when an event is patched. Same shape as event.created; event reflects the post-update state.
{ "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "event": { "id": "evt_01H9X4A1B2C3D4E5F6", "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "title": "Strategy sync with Acme Corp", "start_time": "2026-04-17T15:00:00Z", "end_time": "2026-04-17T15:30:00Z", "description": "Quarterly strategy alignment — rescheduled", "all_day": false, "status": "confirmed", "source": "internal", "metadata": { "deal_id": "deal_789" }, "created_at": "2026-04-16T12:00:00Z", "updated_at": "2026-04-16T13:42:00Z" }}event.deleted
Section titled “event.deleted”Fires when an event is deleted. Only the identifiers are included — the full row is no longer retrievable.
{ "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "event_id": "evt_01H9X4A1B2C3D4E5F6"}event.started
Section titled “event.started”Fires when a confirmed event’s start_time is reached. Dispatched by Chronary’s lifecycle scheduler — not in response to any API call. Stale fires (where the event was rescheduled, cancelled, or deleted) are suppressed automatically.
{ "event_id": "evt_01H9X4A1B2C3D4E5F6", "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "title": "Strategy sync with Acme Corp", "start_time": "2026-04-17T14:00:00Z", "end_time": "2026-04-17T14:30:00Z"}event.ended
Section titled “event.ended”Fires when a confirmed event’s end_time is reached. Same payload shape as event.started.
{ "event_id": "evt_01H9X4A1B2C3D4E5F6", "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "title": "Strategy sync with Acme Corp", "start_time": "2026-04-17T14:00:00Z", "end_time": "2026-04-17T14:30:00Z"}event.reminder
Section titled “event.reminder”Fires ahead of a confirmed event’s start_time, once per resolved reminder offset (the event’s own reminders, or the calendar’s default_reminders, or the system default of [10]). Dispatched by the lifecycle scheduler — not in response to any API call. reminder_minutes identifies which offset fired, so a handler can distinguish the 1-day-before reminder from the 10-minute-before one. Reminders fire only for confirmed events; stale fires (rescheduled, cancelled, or deleted events) are suppressed. See Reminders.
{ "event_id": "evt_01H9X4A1B2C3D4E5F6", "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "title": "Strategy sync with Acme Corp", "start_time": "2026-04-17T14:00:00Z", "end_time": "2026-04-17T14:30:00Z", "reminder_minutes": 10}event.hold_created
Section titled “event.hold_created”Fires when a tentative hold is placed via POST /v1/calendars/:cal_id/events with status: "hold". Payload carries the full event row (same shape as event.created) with status: "hold" and the hold-specific fields populated.
{ "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "event": { "id": "evt_01H9X4A1B2C3D4E5F6", "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "title": "Tentative: client call", "start_time": "2026-04-17T14:00:00Z", "end_time": "2026-04-17T14:30:00Z", "description": null, "all_day": false, "status": "hold", "source": "internal", "metadata": {}, "hold_expires_at": "2026-04-17T13:55:00Z", "hold_priority": 5, "created_at": "2026-04-17T13:50:00Z", "updated_at": "2026-04-17T13:50:00Z" }}event.hold_expired
Section titled “event.hold_expired”Fires when a hold’s TTL elapses or when a higher-priority hold pre-empts it. Only identifiers are included — the event row may have been deleted or mutated by the time the webhook arrives.
{ "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "event_id": "evt_01H9X4A1B2C3D4E5F6"}event.hold_released
Section titled “event.hold_released”Fires when a hold is manually released via PUT /v1/events/:id/release before its TTL. Only identifiers are included.
{ "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "event_id": "evt_01H9X4A1B2C3D4E5F6"}event.hold_confirmed
Section titled “event.hold_confirmed”Fires when a hold is promoted to a confirmed event via PUT /v1/events/:id/confirm. The payload carries the post-promotion event row (same shape as event.created) with status: "confirmed".
{ "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "event": { "id": "evt_01H9X4A1B2C3D4E5F6", "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "title": "Client call", "start_time": "2026-04-17T14:00:00Z", "end_time": "2026-04-17T14:30:00Z", "description": null, "all_day": false, "status": "confirmed", "source": "internal", "metadata": {}, "hold_expires_at": null, "hold_priority": null, "created_at": "2026-04-17T13:50:00Z", "updated_at": "2026-04-17T13:54:00Z" }}proposal.created
Section titled “proposal.created”Fires when an organizer creates a scheduling proposal. Payload contains the fully-expanded proposal with slots and (empty) responses.
{ "proposal": { "id": "spr_01H9X4A1B2C3D4E5F6", "title": "Q2 planning sync", "description": "60-minute working session", "organizer_agent_id": "agt_01H9X4A1B2C3D4E5F6", "participant_agent_ids": ["agt_01H9X4A1B2C3D4E5F7", "agt_01H9X4A1B2C3D4E5F8"], "calendar_id": "cal_01H9X4A1B2C3D4E5F6", "status": "pending", "expires_at": "2026-04-19T12:00:00Z", "resolved_slot": null, "created_event_id": null, "metadata": {}, "created_at": "2026-04-16T12:00:00Z", "updated_at": "2026-04-16T12:00:00Z", "slots": [ { "id": "slt_01H9X4A1B2C3D4E5F6", "start_time": "2026-04-20T14:00:00Z", "end_time": "2026-04-20T15:00:00Z", "weight": 1.0, "calendar_id": null } ], "responses": [] }}proposal.responded
Section titled “proposal.responded”Fires each time a participant accepts, counters, or declines a proposal.
{ "proposal_id": "spr_01H9X4A1B2C3D4E5F6", "agent_id": "agt_01H9X4A1B2C3D4E5F7", "response": "accept"}response is one of accept, counter, or decline.
proposal.confirmed
Section titled “proposal.confirmed”Fires when a proposal resolves to a winning slot (either via explicit POST /resolve or automatically when the last participant responds). A confirmed calendar event is created atomically; its ID is included as created_event_id.
{ "proposal_id": "spr_01H9X4A1B2C3D4E5F6", "resolved_slot": { "id": "slt_01H9X4A1B2C3D4E5F6", "start_time": "2026-04-20T14:00:00Z", "end_time": "2026-04-20T15:00:00Z", "weight": 1.0, "calendar_id": "cal_01H9X4A1B2C3D4E5F6" }, "created_event_id": "evt_01H9X4A1B2C3D4E5F9"}proposal.cancelled
Section titled “proposal.cancelled”Fires when a proposal is cancelled. reason is one of:
organizer_cancelled— explicit cancel viaPOST /v1/scheduling/proposals/:id/cancelall_declined— every participant declined, so auto-resolution cancelled the proposal
{ "proposal_id": "spr_01H9X4A1B2C3D4E5F6", "reason": "organizer_cancelled"}proposal.expired
Section titled “proposal.expired”Fires when a pending proposal’s expires_at is reached. Dispatched by the lifecycle scheduler.
{ "proposal_id": "spr_01H9X4A1B2C3D4E5F6"}Sequence: event.started / event.ended lifecycle
Section titled “Sequence: event.started / event.ended lifecycle”When a confirmed event is created, Chronary schedules two deferred queue messages — one for start_time, one for end_time. At fire time, the scheduler re-reads the event from the database and suppresses the fire if the event was deleted, cancelled, or rescheduled. Only events with status: "confirmed" trigger lifecycle webhooks.
sequenceDiagram participant Agent participant API as Chronary API participant Sched as Lifecycle Scheduler participant Consumer as Your Endpoint
Agent->>API: POST /v1/calendars/:cal_id/events API->>Sched: schedule event.started at start_time API->>Sched: schedule event.ended at end_time API-->>Agent: 201 Created (evt_...) API->>Consumer: POST webhook (event.created)
Note over Sched: time passes...
Sched->>API: fire event.started (re-reads event row) API->>Consumer: POST webhook (event.started)
Note over Sched: event runs...
Sched->>API: fire event.ended (re-reads event row) API->>Consumer: POST webhook (event.ended)If the event is updated (start_time or end_time changes), the scheduler enqueues fresh messages. Stale queue messages from the old schedule are detected and dropped at fire time.
Sequence: temporal hold lifecycle
Section titled “Sequence: temporal hold lifecycle”A tentative hold reserves a slot with a TTL. Only one sequence of events fires per hold depending on how it resolves.
sequenceDiagram participant Agent participant API as Chronary API participant Consumer as Your Endpoint
Agent->>API: POST /v1/calendars/:cal/events (status=hold) API-->>Agent: 201 Created (evt_..., status=hold) API->>Consumer: POST webhook (event.hold_created)
alt Agent promotes the hold before TTL Agent->>API: PUT /v1/events/:id/confirm API->>Consumer: POST webhook (event.hold_confirmed) else Agent releases the hold manually Agent->>API: PUT /v1/events/:id/release API->>Consumer: POST webhook (event.hold_released) else Higher-priority hold pre-empts Note over API: POST /v1/calendars/:cal/events with higher hold_priority overlapping API->>Consumer: POST webhook (event.hold_expired) — for the bumped hold API->>Consumer: POST webhook (event.hold_created) — for the new higher-priority hold else TTL elapses with no action Note over API: hold_expires_at reached API->>Consumer: POST webhook (event.hold_expired) endWhen a new hold bumps an existing one, Chronary fires the event.hold_expired for the bumped hold before the event.hold_created for the new one — see the “Priority bump ordering” note above.
Sequence: proposal flow
Section titled “Sequence: proposal flow”Proposals follow a state machine: pending → (confirmed | cancelled | expired). The exact webhook sequence depends on how the proposal resolves.
sequenceDiagram participant Org as Organizer Agent participant Part as Participant Agents participant API as Chronary API participant Consumer as Your Endpoint
Org->>API: POST /v1/scheduling/proposals API->>Consumer: POST webhook (proposal.created)
loop for each participant response Part->>API: POST /proposals/:id/respond API->>Consumer: POST webhook (proposal.responded) end
alt All participants responded, at least one non-decline API->>API: auto-resolve (pick best slot, create event) API->>Consumer: POST webhook (event.created) API->>Consumer: POST webhook (proposal.confirmed) else All participants declined API->>Consumer: POST webhook (proposal.cancelled) [reason: all_declined] else Organizer cancels before resolution Org->>API: POST /proposals/:id/cancel API->>Consumer: POST webhook (proposal.cancelled) [reason: organizer_cancelled] else Proposal expires while still pending Note over API: expires_at reached API->>Consumer: POST webhook (proposal.expired) endAuto-resolution only fires when every participant has responded. If any participant never responds, the proposal stays pending until the organizer explicitly resolves it (POST /proposals/:id/resolve), cancels it, or it expires.