Skip to content

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.

POST /v1/webhooks

| 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.created — event created with status=confirmed or tentative
  • event.updated
  • event.deleted
  • event.started — fires at the scheduled start_time of a confirmed event
  • event.ended — fires at the scheduled end_time of a confirmed event
  • event.reminder — fires ahead of a confirmed event’s start_time, one per configured reminder offset
  • event.hold_created — tentative hold placed
  • event.hold_expired — hold auto-expired by TTL or pre-empted by higher-priority hold
  • event.hold_released — hold manually released via /release
  • event.hold_confirmed — hold promoted to confirmed event via /confirm
  • agent.created
  • agent.updated
  • proposal.created
  • proposal.responded
  • proposal.confirmed
  • proposal.expired
  • proposal.cancelled
Terminal window
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"]
}'
{
"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.


GET /v1/webhooks

| Parameter | Type | Default | Description | |-----------|------|---------|-------------| | limit | integer | 20 | 1–100 | | offset | integer | 0 | Pagination offset |

Terminal window
curl "https://api.chronary.ai/v1/webhooks" \
-H "Authorization: Bearer chr_sk_your_key_here"

GET /v1/webhooks/:id

| Status | Type | Cause | |--------|------|-------| | 404 | not_found | Webhook not found |


PATCH /v1/webhooks/:id

| Field | Type | Description | |-------|------|-------------| | url | string | New delivery URL | | events | string[] | New event type list | | active | boolean | Enable/disable deliveries |

Terminal window
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 }'

| Status | Type | Cause | |--------|------|-------| | 404 | not_found | Webhook not found |


DELETE /v1/webhooks/:id

Returns 204 No Content.

| Status | Type | Cause | |--------|------|-------| | 404 | not_found | Webhook not found |


Inspect delivery history for a subscription — useful for diagnosing failures, confirming events were received, or auditing retry counts.

GET /v1/webhooks/:id/deliveries

Auth: org-level API key only. Agent-scoped keys (chr_ak_*) return 403.

| 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 falsedo not omit the value, as some query string parsers treat the bare key as truthy. |

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

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


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.

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

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.

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.

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


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);
}

Reject the request if the signature does not match or if X-Timestamp is older than ~5 minutes (to mitigate replay attacks).


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 |

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"
}
}

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"
}
}

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"
}
}

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"
}
}

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"
}

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"
}

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"
}

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
}

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"
}
}

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"
}

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"
}

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"
}
}

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": []
}
}

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.

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"
}

Fires when a proposal is cancelled. reason is one of:

  • organizer_cancelled — explicit cancel via POST /v1/scheduling/proposals/:id/cancel
  • all_declined — every participant declined, so auto-resolution cancelled the proposal
{
"proposal_id": "spr_01H9X4A1B2C3D4E5F6",
"reason": "organizer_cancelled"
}

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.


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)
end

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


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)
end

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