Webhooks
Receive real-time event notifications from the Aurion API via webhooks.
Webhooks
Webhooks notify your application in real time when events occur in Aurion — such as ticket creation, status changes, or call completions.
Register a Webhook
curl -X POST "https://apps.aurionai.net/api/v1/webhooks" \
-H "X-API-Key: itsm_sk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/aurion",
"events": ["ticket.created", "ticket.updated", "call.completed"]
}'The request body accepts only url (must start with https://) and a
non-empty events array. The signing secret is generated by Aurion,
not supplied by you — any secret you send is ignored.
Response (201 Created):
{
"id": "wh_abc123",
"url": "https://your-app.com/webhooks/aurion",
"events": ["ticket.created", "ticket.updated", "call.completed"],
"is_active": true,
"secret": "0Hn3kq7pXr2sV9wY4zB6dF8gJ1mN5tQ-c0e2",
"created_at": "2026-02-20T14:30:00Z",
"updated_at": "2026-02-20T14:30:00Z"
}⚠️ Save the secret now
The
secretis returned only in this create response — it is never shown again by any other endpoint. Store it securely; you need it to verify webhook signatures. If you lose it, delete the webhook and register a new one.
The url is validated when you register: it must use https://, and it
runs through an SSRF allowlist (a private/internal or non-resolving host
is rejected with 400 {"detail": "..."}). Unknown event names are also
rejected with 400 {"detail": "Invalid event type: ... Valid events: ..."}.
List Webhooks
curl "https://apps.aurionai.net/api/v1/webhooks" \
-H "X-API-Key: itsm_sk_live_xxxx"The list response does not include the secret — it is returned
only by the create endpoint.
Response:
{
"webhooks": [
{
"id": "wh_abc123",
"url": "https://your-app.com/webhooks/aurion",
"events": ["ticket.created", "ticket.updated"],
"is_active": true,
"created_at": "2026-02-20T14:30:00Z"
}
]
}Delete a Webhook
curl -X DELETE "https://apps.aurionai.net/api/v1/webhooks/wh_abc123" \
-H "X-API-Key: itsm_sk_live_xxxx"Returns 204 No Content on success, or 404 {"detail": "Webhook not found"}
if the id does not exist.
ℹ️ No public update endpoint
There is no public API-key endpoint to edit a webhook (for example, to toggle it active/inactive or change its event list). Updating a webhook is a dashboard-only operation (
PATCH /api/v2/webhooks/{id}, reachable only with a dashboard session, not an API key). To change a webhook's URL or events, delete it and register a new one.
Event Types
The table below is the authoritative public catalog of event types. (A
live catalog endpoint exists at GET /api/v2/webhooks/event-types, but
it is reachable only with a dashboard session — an API key gets 403
there, so it is not part of the public surface.) If an event is not
listed here, the create endpoint rejects it with
400 {"detail": "Invalid event type: ... Valid events: ..."}.
Conversations:
| Event | Description |
|---|---|
conversation.created | A new conversation was created in the inbox |
conversation.assigned | A conversation was assigned to an agent or team |
conversation.resolved | A conversation was marked resolved |
conversation.reopened | A resolved conversation was reopened |
conversation.note_added | An internal note was added to a conversation |
Messages:
| Event | Description |
|---|---|
message.sent | An outbound message was sent on a conversation |
message.received | An inbound message was received on a conversation |
Contacts:
| Event | Description |
|---|---|
contact.created | A new CS contact was created |
contact.updated | A CS contact was updated |
contact.deleted | A CS contact was deleted |
Tickets:
| Event | Description |
|---|---|
ticket.created | A new ITSM ticket was created |
ticket.updated | An ITSM ticket was updated |
ticket.replied | A reply was added to an ITSM ticket |
ticket.resolved | An ITSM ticket was marked resolved |
Calls:
| Event | Description |
|---|---|
call.started | A voice call started |
call.completed | A voice call completed |
call.escalated | A voice call was escalated to a human agent |
Knowledge Base:
| Event | Description |
|---|---|
kb.article.published | A KB article was published |
kb.article.updated | A KB article was updated |
System:
| Event | Description |
|---|---|
usage.reported | A usage snapshot was reported to billing |
config.updated | Tenant configuration was updated |
Use the literal string * when creating a subscription to receive every
event type in the catalog.
Webhook Payload
Each webhook delivery sends a POST request with a JSON body. Every
delivery uses the same top-level envelope — event_type, timestamp
(ISO-8601 UTC), and data — and the resource always sits under a named
key inside data (data.ticket, data.call, data.conversation,
data.contact, data.message). There is no top-level event id:
{
"event_type": "ticket.created",
"timestamp": "2026-02-20T14:30:00Z",
"data": {
"ticket": {
"id": 1042,
"subject": "VPN not connecting",
"status": "open",
"priority": "high"
}
}
}To deduplicate deliveries, use the X-Webhook-Event header together
with the resource's own id (e.g. data.ticket.id) — the envelope
itself does not carry a delivery-level identifier.
Knowledge-base events
kb.article.published fires once per successful provider publish from
the KB v2 pipeline (POST /api/kb/create-v2) — never on intermediate
pipeline stages. Multi-destination publishes emit one delivery per
successful provider write.
kb.article.updated fires when an existing article in the AurionAI
help-center KB is patched (PATCH /api/v2/kb/articles/{id}).
Both events share the same envelope so a search-indexer or RAG ingestion pipeline can react without re-fetching the article body:
{
"event_type": "kb.article.published",
"timestamp": "2026-04-26T14:30:00Z",
"data": {
"article": {
"id": "wG4nW91X",
"title": "How to reset your VPN client",
"status": "published",
"url": "https://your-tenant.freshservice.com/support/solutions/articles/wG4nW91X",
"provider_id": "itsm",
"tags": ["vpn", "networking"],
"category": "cat-7",
"language": "en",
"updated_at": "2026-04-26T14:29:58Z"
}
}
}provider_id is "itsm" for upstream ITSM-published articles
(Freshservice, HaloITSM, ServiceNow, JSM) and "aurion" for the
AurionAI help-center KB. url is null for AurionAI-local articles —
subscribers should resolve it via the tenant's help-center base URL.
Voice call lifecycle events
call.started fires once per call, immediately after the call record
is durably persisted in the database. Subscribers can use the call
id to fetch the live record (recording status, transcript exchanges)
while the call is in progress.
call.completed fires once per call, after the recording has been
uploaded, the LLM summary generated, the auto-ticket enforced (if
applicable), and the voice-inbox conversation recorded. This is the
end-of-call event a CTI dashboard or post-call automation should hook
on. It always fires — even when outcome is escalated, in which case
call.escalated fires earlier.
call.escalated fires when the agent transfers the caller to a human
(via the escalate_to_human tool) and the underlying transfer service
returns status='transferred'. Failed escalations (max retries, no
target configured, non-Twilio call) do not fire this event.
All three events share the same data.call envelope and are emitted
best-effort — webhook fan-out failures never block the call
lifecycle. Delivery retries are handled by the standard webhook
delivery loop.
{
"event_type": "call.completed",
"timestamp": "2026-04-26T17:32:30Z",
"data": {
"call": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"tenant_id": "11111111-2222-3333-4444-555555555555",
"started_at": "2026-04-26T17:30:00Z",
"ended_at": "2026-04-26T17:32:30Z",
"duration_seconds": 150,
"outcome": "ticket_created",
"caller_phone": "+33612345678",
"channel": "phone",
"direction": "inbound",
"twilio_call_sid": "CAxxxxxxxxxxxxxxxx",
"recording_url": "s3://aurion-recordings/2026/04/26/550e8400.wav",
"ticket_id": "FRESH-1042",
"detected_language": "fr-FR"
}
}
}| Event | Required fields | Optional fields |
|---|---|---|
call.started | id, tenant_id, started_at, direction, channel | caller_phone, twilio_call_sid, livekit_room_id |
call.completed | id, tenant_id, started_at, ended_at, duration_seconds, outcome | recording_url, ticket_id, detected_language, caller_phone, twilio_call_sid |
call.escalated | id, tenant_id, escalated_at, reason | escalation_target, escalation_id, caller_phone, twilio_call_sid |
outcome (on call.completed) is one of ticket_created,
status_checked, kb_article, escalated, hung_up, error.
reason (on call.escalated) is one of user_requested,
low_confidence, repeated_failure, system_error, out_of_scope,
auth_failure.
Contact lifecycle events
contact.created, contact.updated, and contact.deleted fire from
two write paths: the dashboard's CS contacts API (POST / PATCH /
DELETE on /api/v2/contacts) and the Freshdesk/Zendesk sync CronJob
(/api/sync/contacts). The two paths share the same envelope so a
CRM-mirror integration can subscribe once and process every contact
mutation regardless of origin:
{
"event_type": "contact.updated",
"timestamp": "2026-04-26T18:00:00Z",
"data": {
"contact": {
"id": "1f3a8b1c-4d5e-6f70-8192-a3b4c5d6e7f8",
"external_id": "fd_31415",
"first_name": "Alice",
"last_name": "Wonder",
"name": "Alice Wonder",
"email": "alice@example.com",
"phone": "+33612345678",
"source": "freshdesk_sync"
}
}
}The source field identifies the origin of the write so a CRM can
filter sync-mirrored traffic from human-driven changes:
source | Origin |
|---|---|
freshdesk_sync | Mirrored from Freshdesk via /api/sync/contacts |
zendesk_sync | Mirrored from Zendesk via /api/sync/contacts |
| absent | Created or modified via the dashboard /api/v2/contacts API |
Sync emissions are diff-aware — a CronJob cycle that finds zero
upstream changes emits zero contact.* events. Subscribers do not need
to dedupe identical-payload deliveries from idle sync runs. When the
sync detects a real change, exactly one contact.updated is emitted
per row regardless of how many fields changed in that row. A full
sync's reconciliation pass emits one contact.updated per stale
contact deactivated.
A successful POST /api/v2/contacts/{id}/restore (un-soft-delete)
emits contact.updated so subscribers that previously processed
contact.deleted for that id can mark the contact alive again.
The dashboard CRUD path and the sync path emit slightly different
field sets — the legacy contact table written by sync carries
mobile and active, while the modern aurion_contacts table
written by /api/v2/contacts does not. Subscribers should treat
every non-id field as optional.
Dashboard-emitted (source absent):
| Event | Required | Optional |
|---|---|---|
contact.created | id | external_id, first_name, last_name, name, email, phone, company_id, company_name |
contact.updated | id | same as contact.created |
contact.deleted | id | same as contact.created |
Sync-emitted (source is freshdesk_sync or zendesk_sync):
| Event | Required | Optional |
|---|---|---|
contact.created | id | external_id, provider_type, source, first_name, last_name, name, email, phone, mobile, company_name, active |
contact.updated | id | same as contact.created |
Conversation lifecycle events
conversation.created, conversation.assigned, conversation.resolved,
conversation.reopened, and conversation.note_added fire from the
inbox lifecycle: the agent inbox API (POST /api/v2/conversations,
PATCH /api/v2/conversations/{id}, POST /api/v2/conversations/{id}/assign,
POST /api/v2/conversations/{id}/messages with is_internal=true),
plus the inbound channel routers (widget chat at
POST /api/v1/widget/messages, WhatsApp at the Meta webhook handler,
and inbound email via SendGrid Parse).
All events share a data.conversation envelope keyed by the
conversation id. Additional event-specific fields (assignee, previous
status) are surfaced at the top level of data so subscribers can
react without re-fetching the full conversation:
{
"event_type": "conversation.created",
"timestamp": "2026-04-26T18:30:00Z",
"data": {
"conversation": {
"id": "9b1d3f4e-2c5a-4b6d-8e7f-1a2b3c4d5e6f",
"tenant_id": "11111111-2222-3333-4444-555555555555",
"status": "open",
"channel": "email",
"contact_id": "1f3a8b1c-4d5e-6f70-8192-a3b4c5d6e7f8",
"subject": "VPN issue",
"priority": "medium"
}
}
}Status-transition events (conversation.resolved / conversation.reopened)
fire only when the status actually crosses the open/closed boundary.
Same-state PATCHes (open → open), unrelated field edits (priority
change, tag add), and same-side transitions (open → waiting) emit
nothing. The previous status is surfaced under data.previous_status:
{
"event_type": "conversation.resolved",
"timestamp": "2026-04-26T18:35:00Z",
"data": {
"conversation": {
"id": "9b1d3f4e-2c5a-4b6d-8e7f-1a2b3c4d5e6f",
"status": "resolved",
"channel": "email"
},
"previous_status": "open"
}
}closed is treated equivalently to resolved for catalog purposes —
the inbox UI exposes a single "resolved" verb, so subscribers see one
event for either terminal state.
conversation.assigned carries the assignee identity and routing
reason, which lets routing/CRM dashboards surface the picker outcome
without hitting the activity-log API:
{
"event_type": "conversation.assigned",
"timestamp": "2026-04-26T18:32:00Z",
"data": {
"conversation": {
"id": "9b1d3f4e-2c5a-4b6d-8e7f-1a2b3c4d5e6f",
"assignee_user_id": "609f679f-9384-4b4b-b0e1-a1fb9dd8a401",
"assignee_team_id": null
},
"routing_reason": "manual_assignment"
}
}routing_reason is one of manual_assignment, manual_reassignment,
auto_assignment, auto_reassignment, or a team's configured routing
algorithm (e.g. balanced, round_robin).
| Event | Required | Optional |
|---|---|---|
conversation.created | conversation.id | tenant_id, status, channel, contact_id, subject, priority |
conversation.assigned | conversation.id | assignee_user_id, assignee_team_id, top-level routing_reason |
conversation.resolved | conversation.id, conversation.status | channel, contact_id, subject, top-level previous_status |
conversation.reopened | conversation.id, conversation.status | same as resolved |
conversation.note_added | conversation.id, note.id | note.body, note.sender_id, note.created_at |
Message lifecycle events
message.sent fires when an outbound message is persisted on a
conversation — agent reply via POST /api/v2/conversations/{id}/messages
with is_internal=false, AI auto-reply on the widget/WhatsApp inbound
path. The webhook is enqueued before the optional outbound channel
send (email / WhatsApp), so subscribers see the persistence even when
the downstream send fails (the underlying message row carries
metadata.email_delivery indicating the send result).
message.received fires when an inbound message is persisted from any
ingestion path (widget, WhatsApp, email).
Internal notes (is_internal=true) emit conversation.note_added
instead of message.sent — the catalog gives notes their own event so
subscribers can route them differently. There is no double-fire: a
single internal note creates exactly one conversation.note_added
delivery row and zero message.sent rows.
{
"event_type": "message.received",
"timestamp": "2026-04-26T18:31:00Z",
"data": {
"conversation": {
"id": "9b1d3f4e-2c5a-4b6d-8e7f-1a2b3c4d5e6f"
},
"message": {
"id": "abc12345-6789-4def-8aaa-bbbbccccdddd",
"conversation_id": "9b1d3f4e-2c5a-4b6d-8e7f-1a2b3c4d5e6f",
"sender_type": "contact",
"sender_id": "1f3a8b1c-4d5e-6f70-8192-a3b4c5d6e7f8",
"body": "I still can't connect to the VPN.",
"content_type": "text",
"is_internal": false,
"source": "email"
}
}
}source mirrors the channel that produced the message
(dashboard, widget, whatsapp, email). sender_type is one of
contact (for inbound), agent, or ai (for outbound).
| Event | Required | Optional |
|---|---|---|
message.sent | message.id, message.conversation_id | body, sender_type, sender_id, content_type, source, created_at |
message.received | message.id, message.conversation_id, message.sender_type=contact | body, sender_id, content_type, source, created_at |
All conversation and message emissions are best-effort: a webhook fan-out failure (network error, downstream 5xx, SSRF block) never rolls back the underlying conversation/message persistence, and never surfaces as an error to the inbox UI.
Delivery Headers
Every webhook delivery carries these headers:
| Header | Description |
|---|---|
X-Webhook-Event | The event type (e.g. ticket.created) |
X-Webhook-Timestamp | The ISO-8601 UTC timestamp used in the signed message |
X-Webhook-Signature | HMAC-SHA256 of the signed message, as a bare lowercase hex digest (no sha256= prefix) |
Signature Verification
Each delivery is signed with your webhook secret using HMAC-SHA256. The
signed message is not the raw body alone — it is the
X-Webhook-Timestamp value, a literal ., and then the raw request
body bytes:
signed_message = f"{X-Webhook-Timestamp}.".encode("utf-8") + raw_body
X-Webhook-Signature = HMAC_SHA256(secret, signed_message).hexdigest()The signature value in X-Webhook-Signature is the bare hex digest —
compare against it directly (do not prepend sha256=). Always verify
the signature before processing the event.
import hmac
import hashlib
def verify_webhook(payload: bytes, timestamp: str, signature: str, secret: str) -> bool:
message = f"{timestamp}.".encode("utf-8") + payload
expected = hmac.new(
secret.encode("utf-8"),
message,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
# In your webhook handler:
# payload = request.body (raw bytes)
# timestamp = request.headers["X-Webhook-Timestamp"]
# signature = request.headers["X-Webhook-Signature"]
is_valid = verify_webhook(
payload, timestamp, signature, "0Hn3kq7pXr2sV9wY4zB6dF8gJ1mN5tQ-c0e2"
)import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhook(
payload: string,
timestamp: string,
signature: string,
secret: string
): boolean {
const expected = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(payload)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
// In your webhook handler:
// payload = req.body (raw string)
// timestamp = req.headers["x-webhook-timestamp"]
// signature = req.headers["x-webhook-signature"]
const isValid = verifyWebhook(
payload,
timestamp,
signature,
"0Hn3kq7pXr2sV9wY4zB6dF8gJ1mN5tQ-c0e2"
);Retry Policy
If your endpoint returns a non-2xx status code or the request times out
(10 seconds), Aurion retries delivery with exponential backoff measured
in seconds. After each failed attempt, the next attempt is scheduled
2^attempts seconds later, capped at 2^6 (64 seconds):
| Attempt | Delay before next attempt |
|---|---|
| 1st | ~2 seconds |
| 2nd | ~4 seconds |
| 3rd | ~8 seconds |
| 4th | ~16 seconds |
| 5th | ~32 seconds |
A delivery is attempted at most 5 times. After the 5th failed attempt
the delivery is marked failed. You can view delivery logs and retry
failed events from the admin dashboard under Configuration > Webhooks.
Delivery Logs
List recent deliveries for a webhook:
curl "https://apps.aurionai.net/api/v1/webhooks/wh_abc123/deliveries?limit=10" \
-H "X-API-Key: itsm_sk_live_xxxx"limit defaults to 50 and may be 1–200. Deliveries are returned
most-recent first.
Response:
{
"deliveries": [
{
"id": "del_abc123",
"event_type": "ticket.created",
"status": "delivered",
"attempts": 1,
"last_attempt_at": "2026-02-20T14:30:01Z",
"next_attempt_at": null,
"response_status": 200,
"duration_ms": 142,
"error": null,
"created_at": "2026-02-20T14:30:00Z"
}
]
}Each log records the event type, delivery status (pending,
delivered, or failed), the number of attempts, the last/next
attempt timestamps, the HTTP response_status your endpoint returned,
the duration_ms of the request, and any error string. The public
delivery log does not include the request payload that was sent or
your endpoint's response body.
Best Practices
- Respond quickly — Return a
200immediately, then process the event asynchronously - Handle duplicates — Deduplicate on the
X-Webhook-Eventheader plus the resourceidinsidedata(e.g.data.ticket.id); the envelope has no top-level delivery id - Verify signatures — Always validate the HMAC signature before processing
- Use HTTPS — Webhook URLs must use
https://(enforced at registration)
Related Guides
- Tickets — Ticket CRUD operations
- Conversations — Omnichannel conversation management
- Calls & Recordings — Voice call data and transcripts
- CSAT Surveys — Customer satisfaction surveys
- Automation & Workflows — Workflow webhook triggers
Required Scopes
The API key's required scope depends on the operation:
| Operation | Scope |
|---|---|
Create a webhook (POST /api/v1/webhooks) | webhooks:write |
List webhooks (GET /api/v1/webhooks) | webhooks:read |
Read delivery logs (GET /api/v1/webhooks/{id}/deliveries) | webhooks:read |
Delete a webhook (DELETE /api/v1/webhooks/{id}) | webhooks:delete |
The wildcard scope * grants all of the above. (There is no
webhooks:manage scope — it is never checked and would not grant
access.) Updating a webhook is not part of the public API surface — see
the note under Delete a Webhook.