AurionAI Docs

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 secret is 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:

EventDescription
conversation.createdA new conversation was created in the inbox
conversation.assignedA conversation was assigned to an agent or team
conversation.resolvedA conversation was marked resolved
conversation.reopenedA resolved conversation was reopened
conversation.note_addedAn internal note was added to a conversation

Messages:

EventDescription
message.sentAn outbound message was sent on a conversation
message.receivedAn inbound message was received on a conversation

Contacts:

EventDescription
contact.createdA new CS contact was created
contact.updatedA CS contact was updated
contact.deletedA CS contact was deleted

Tickets:

EventDescription
ticket.createdA new ITSM ticket was created
ticket.updatedAn ITSM ticket was updated
ticket.repliedA reply was added to an ITSM ticket
ticket.resolvedAn ITSM ticket was marked resolved

Calls:

EventDescription
call.startedA voice call started
call.completedA voice call completed
call.escalatedA voice call was escalated to a human agent

Knowledge Base:

EventDescription
kb.article.publishedA KB article was published
kb.article.updatedA KB article was updated

System:

EventDescription
usage.reportedA usage snapshot was reported to billing
config.updatedTenant 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"
    }
  }
}
EventRequired fieldsOptional fields
call.startedid, tenant_id, started_at, direction, channelcaller_phone, twilio_call_sid, livekit_room_id
call.completedid, tenant_id, started_at, ended_at, duration_seconds, outcomerecording_url, ticket_id, detected_language, caller_phone, twilio_call_sid
call.escalatedid, tenant_id, escalated_at, reasonescalation_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:

sourceOrigin
freshdesk_syncMirrored from Freshdesk via /api/sync/contacts
zendesk_syncMirrored from Zendesk via /api/sync/contacts
absentCreated 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):

EventRequiredOptional
contact.createdidexternal_id, first_name, last_name, name, email, phone, company_id, company_name
contact.updatedidsame as contact.created
contact.deletedidsame as contact.created

Sync-emitted (source is freshdesk_sync or zendesk_sync):

EventRequiredOptional
contact.createdidexternal_id, provider_type, source, first_name, last_name, name, email, phone, mobile, company_name, active
contact.updatedidsame 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 (openopen), unrelated field edits (priority change, tag add), and same-side transitions (openwaiting) 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).

EventRequiredOptional
conversation.createdconversation.idtenant_id, status, channel, contact_id, subject, priority
conversation.assignedconversation.idassignee_user_id, assignee_team_id, top-level routing_reason
conversation.resolvedconversation.id, conversation.statuschannel, contact_id, subject, top-level previous_status
conversation.reopenedconversation.id, conversation.statussame as resolved
conversation.note_addedconversation.id, note.idnote.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).

EventRequiredOptional
message.sentmessage.id, message.conversation_idbody, sender_type, sender_id, content_type, source, created_at
message.receivedmessage.id, message.conversation_id, message.sender_type=contactbody, 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:

HeaderDescription
X-Webhook-EventThe event type (e.g. ticket.created)
X-Webhook-TimestampThe ISO-8601 UTC timestamp used in the signed message
X-Webhook-SignatureHMAC-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.

Python
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"
)
TypeScript
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):

AttemptDelay 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 1200. 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 200 immediately, then process the event asynchronously
  • Handle duplicates — Deduplicate on the X-Webhook-Event header plus the resource id inside data (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)

Required Scopes

The API key's required scope depends on the operation:

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

On this page