Escalate a conversation to a ticket
Open a ticket from your own system when a customer needs a human — requester details, choosing a priority, the dedupe that stops a retry becoming two tickets, and syncing status back with webhooks.
Some conversations end with a person. A refund outside policy, a document that will not validate, an angry customer who has asked twice — these need a human, and the moment they do you want a ticket with a reference number the customer can quote.
This page builds that path: your system decides a human is needed, opens a ticket with the context already attached, hands the customer a reference, and keeps its own record in step as your team works it.
When you use this, and when you do not #
Your agent already escalates on its own. When a customer asks for a human, or the agent hits something it cannot resolve, a ticket is opened without your involvement. You do not need to build that.
Build this when your system is the one that knows:
- A payment was captured and no order exists. Your reconciliation job found it; no conversation happened at all.
- A fulfilment exception your agent cannot see — a courier returned a parcel, a warehouse flagged a mismatch.
- A rule that lives in your code: third failed delivery attempt, second refund request this month, an account flagged for review.
- Your own product's "contact support" form, which you would rather land in the same queue than in a shared mailbox.
The common thread is that the trigger is in your system, not in the conversation.
One scope covers create and read
Creating a ticket and reading one back both need tickets:write. There is no tickets:read scope. If your escalation flow also pulls conversation context, the same key needs conversations:read as well. See Scopes.
The requester is not optional #
A ticket with no requester is a ticket nobody can answer, so the route refuses one:
{"error": {"code": "invalid_argument","message": "Provide at least one requester detail (name, email, or phone).","details": {},"request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4","correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"}}
Any one of requester_name, requester_email, requester_phone satisfies it. Note the empty details — this error carries no reason, so branch on error.code and the message rather than looking for a reason string.
| Field | Limit | What it does for the person working the ticket |
|---|---|---|
requester_name |
120 characters | Tells them who this is. Without it the queue reads as a list of addresses. |
requester_email |
200 characters | The reply path most teams reach for first. |
requester_phone |
32 characters | The reply path that works when the conversation started on WhatsApp or a call. |
Send everything you have. The hard case is when you have nothing — a web-chat visitor who never filled in a lead form has an empty customer object on the conversation, and building requester_email from it gives you null.
Do not let an anonymous customer 400 your escalation
Check before you post. If every requester field would be null, synthesise a name from what you do know — "Web visitor · con_01JQRS4T8XZ0AB1CD2EF3GH4" is a legitimate requester_name and it is infinitely more useful to your team than a failed API call. The sample code below does exactly this.
Choosing a priority #
priority is not decoration. sla_due_at is computed from it when the ticket is created:
priority |
sla_due_at |
Use it when |
|---|---|---|
urgent |
2 hours after creation | The customer is blocked and money or safety is involved. A captured payment with no order. A live outage affecting one named customer. Abuse or a threat. |
high |
8 hours after creation | Blocked with no workaround, or a hard deadline today. A failed KYC that stops onboarding. A delivery that must move before the courier cut-off. |
normal |
24 hours after creation | The default. The customer wants an answer, not a rescue. Most escalations belong here and putting them here is not a failure of care. |
low |
72 hours after creation | No one is waiting. Feedback, a question about a future purchase, a duplicate of something already in flight. |
Two properties to design around.
The clock is set once, at creation. Changing priority later in the console does not recompute sla_due_at. Pass the right value from your system — you know how bad the failure is at the moment you detect it, and nobody will fix it up afterwards.
Escalating everything to urgent deletes the signal. A queue where every ticket is due in two hours is a queue with no ordering. Map priority from a rule in your code — amount at risk, customer tier, how many times this has already happened — rather than from how the exception felt to the developer who wrote the handler.
Send only the four values. Anything else fails validation with a 400 and details.errors naming the field.
Tags #
Up to 10 tags, each one a filter in your team's queue. Route by them: billing, kyc, courier, refund.
The server appends api to whatever you send:
{ "tags": ["refund", "escalated"] }
{ "tags": ["refund", "escalated", "api"] }
That tag is how your team separates machine-created tickets from ones a person or an agent opened — which matters the first time your integration misfires and files forty tickets at 2 a.m. It occupies a slot on the ticket, so plan for eleven where you sent ten.
The dedupe that saves you #
This is the part worth reading twice, because a retried escalation opening two tickets is a real incident: two people work the same problem, the customer gets two references, and your queue metrics lie.
POST /tickets deduplicates through two independent mechanisms.
With an Idempotency-Key header #
Standard behaviour. The stored 201 is replayed for 24 hours, and the key is scoped to your workspace, that specific API key, and this endpoint — so escalate-con_01JQ… on /tickets cannot collide with the same string on /messages. Reusing the key with a different body is a 409 with details.reason of idempotency_key_reuse.
Without one #
Tickets still deduplicate, on a SHA-256 hash of the raw request body. An identical body sent twice converges on the same ticket rather than opening a second one. The ticket id is derived from that hash, which is why tkt_… is a hash rather than a time-ordered id like every other identifier in this API.
This is a genuine safety net and it is why a naive retry loop against this endpoint is usually harmless. But it is a hash of bytes:
// Retried three times by your job runner after a timeout.// Byte-identical body each time → ONE ticket, returned three times.await createTicket(payload);// Same incident, but the description is rebuilt with a timestamp inside it.// Different bytes → THREE tickets.await createTicket({ ...payload, description: `${detail} (checked ${new Date()})` });
Anything that varies between attempts breaks it: a timestamp in the description, a trace id in the subject, a dictionary that serialises with a different key order, a transcript excerpt that gained a turn between attempt one and attempt two. That last one is the trap specific to this recipe — the context you attach is live data, and live data changes.
Send the header anyway, and derive it from your own record
The body hash protects you from a byte-identical retry. The header protects you from everything, because it lets you declare what "the same escalation" means. escalate-con_01JQRS4T8XZ0AB1CD2EF3GH4 says: this conversation needs a human, once, however many times this handler runs. If a single conversation can legitimately escalate twice for two different reasons, put your own incident id in the key instead — escalate-inc-99231. Never Date.now().
One boundary: idempotency records live for 24 hours. The same key 25 hours later opens a fresh ticket, which is correct for a retry queue and wrong for a job replayed from an archive. Check your own "already escalated" flag before calling. See Idempotency.
The complete escalation #
This handler takes a conversation_id and a reason, pulls enough context that your team does not have to go looking, and opens the ticket. Both versions are runnable.
# escalate.pyimport osimport requestsBASE_URL = "https://services.amomic.in/api/v1"API_KEY = os.environ["AMOMIC_API_KEY"] # needs tickets:write + conversations:readHEADERS = {"Authorization": f"Bearer {API_KEY}"}DESCRIPTION_LIMIT = 8000 # hard API limitSUBJECT_LIMIT = 240 # hard API limitclass AmomicError(Exception):def __init__(self, status: int, error: dict):super().__init__(f"{error['code']}: {error['message']}")self.status = statusself.code = error["code"]self.details = error.get("details") or {}self.reason = self.details.get("reason")self.request_id = error.get("request_id")def _raise_for_error(response: requests.Response) -> dict:if response.ok:return response.json()raise AmomicError(response.status_code, response.json()["error"])def _requester(conversation: dict) -> dict:"""At least one of name/email/phone must be present, so never return all-null."""customer = conversation.get("customer") or {}name = customer.get("name") or customer.get("display_name")email = customer.get("email")phone = customer.get("phone")if not (name or email or phone):# An anonymous web visitor. A synthesised name beats a 400.name = f"Customer · {conversation['conversation_id']}"return {"requester_name": (name or None) and name[:120],"requester_email": (email or None) and email[:200],"requester_phone": (phone or None) and phone[:32],}LABELS = {"user": "Customer", "model": "Agent", "human": "Team"}def _recent_turns(conversation_id: str, turns: int = 12) -> str:"""Last few turns, oldest first. Roles: user (customer), model (agent), human (your team).Reads the first 200 turns; follow next_cursor for longer threads."""page = _raise_for_error(requests.get(f"{BASE_URL}/conversations/{conversation_id}/messages",headers=HEADERS,params={"limit": 200},timeout=30,))lines = [f"{LABELS.get(m.get('role'), 'Agent')}: {m.get('text') or ''}"for m in page["items"][-turns:]]return "\n".join(lines)def escalate(conversation_id: str, reason: str, priority: str = "normal", tags=()) -> dict:conversation = _raise_for_error(requests.get(f"{BASE_URL}/conversations/{conversation_id}", headers=HEADERS, timeout=30))transcript = _recent_turns(conversation_id)description = (f"{reason}\n\n"f"Channel: {conversation.get('channel')}\n"f"Conversation: {conversation_id}\n"f"Turns so far: {conversation.get('message_count', 0)}\n\n"f"--- recent transcript ---\n{transcript}")[:DESCRIPTION_LIMIT]body = {"subject": f"Escalated: {reason}"[:SUBJECT_LIMIT],"description": description,"priority": priority,"tags": list(tags)[:10],**_requester(conversation),}response = requests.post(f"{BASE_URL}/tickets",headers={**HEADERS,"Content-Type": "application/json",# One escalation per conversation. Not a timestamp, not a UUID."Idempotency-Key": f"escalate-{conversation_id}",},json=body,timeout=30,)ticket = _raise_for_error(response)# public_ref is what you quote to the customer; ticket_id is your handle.record_escalation(conversation_id, ticket["ticket_id"], ticket["public_ref"])return ticket
Three details in that code doing real work:
- Every string is truncated to the API limit before it is sent.
subjectover 240 ordescriptionover 8000 is a400, and a transcript excerpt is exactly the kind of field that grows past a limit in production but not in your tests. _requesternever returns three nulls. The400it prevents would otherwise fire only for anonymous visitors, which is to say rarely, at night, in the branch you did not test.- The idempotency key names the conversation, not the moment. A job runner that retries this handler four times files one ticket.
There is no `conversation_id` field on a ticket
The API has no field that formally links a ticket to a conversation, so the link lives in the text — put conversation_id in the description, as the sample does, and store the pairing on your side. Your team can then find the thread in the inbox, and your own systems can join the two records.
What comes back #
{"ticket_id": "tkt_4f9b21c7de03a85610bb47e2","public_ref": "AM-20AB7B43","subject": "Escalated: refund requested outside the 30-day window","status": "new","priority": "high","tags": ["refund", "policy-exception", "api"],"requester": {"name": "Asha Menon","email": "asha.menon@example.in","phone": "+919876543210"},"sla_due_at": "2026-08-01T18:47:03.551204+00:00","created_at": "2026-08-01T10:47:03.551204+00:00","updated_at": "2026-08-01T10:47:03.551204+00:00"}
Two identifiers, two audiences. public_ref (AM-20AB7B43) is the human handle — eight uppercase hex characters, short enough to read out over the phone. Put it in your confirmation message, your status page, and your own UI. ticket_id (tkt_…) is the machine handle and the argument to GET /tickets/{ticket_id}. Store both.
Read only the documented fields
The response is the ticket record, and that record carries more than the fields above — assignment, escalation bookkeeping, internal versioning. Those are not part of the API surface and can change shape or disappear without notice. Read the fields in the endpoint reference and ignore the rest. Do not write a schema validator that rejects unknown keys.
Telling the customer #
The escalation is only useful to the customer if they hear about it. If the conversation is on WhatsApp and they messaged you recently, the 24-hour window is open and you can send free-form text straight back into the thread:
curl -X POST https://services.amomic.in/api/v1/messages \-H "Authorization: Bearer $AMOMIC_API_KEY" \-H "Content-Type: application/json" \-H "Idempotency-Key: escalate-notice-con_01JQRS4T8XZ0AB1CD2EF3GH4" \-d '{"to": "+919876543210","text": "I have passed this to our team as AM-20AB7B43. Someone will come back to you today — you can quote that reference any time."}'
If the window has closed, free-form text returns a 409 with session_window_closed and you need an approved template instead. The 24-hour window explains the rule; Templates covers getting one approved.
Reading the ticket back #
/api/v1/tickets/{ticket_id}Scope tickets:write — the same one that created it. No query parameters. Returns the same object with the current status, priority, tags, and updated_at.
curl https://services.amomic.in/api/v1/tickets/tkt_4f9b21c7de03a85610bb47e2 \-H "Authorization: Bearer $AMOMIC_API_KEY"
The thread — staff replies, customer messages, internal notes — is not included. This returns the ticket record only; the conversation on the ticket lives in the console.
An unknown id, or one belonging to another workspace, returns 404 not_found with Ticket not found. The two cases are deliberately indistinguishable.
There is no list endpoint or public comment endpoint. Status, priority, category, and tags can be changed with PATCH /tickets/{ticket_id} sending the ticket's current version as expected_version.
Syncing status into your own helpdesk #
Do not poll GET /tickets/{ticket_id} on a timer. Subscribe to ticket.created, ticket.updated and ticket.replied and let the status come to you.
{"id": "evt_9f2c4b1e7a3d5c8f0b2e6d41","type": "ticket.updated","created_at": "2026-08-01T13:22:41Z","tenant_id": "ten_01JQRS4T8XZ0AB1CD2EF3GH4","data": {"ticket_id": "tkt_4f9b21c7de03a85610bb47e2","public_ref": "AM-20AB7B43","subject": "Escalated: refund requested outside the 30-day window","status": "resolved","priority": "high","channel": "api","event_id": "evt_01JQRS4T8XZ0AB1CD2EF3GH4"}}
Both events carry the same six fields plus event_id. That is usually the entire payload a status sync needs — the id to join on, the reference to display, and the state to mirror.
Four rules for the handler:
- Verify the signature against the raw request bytes. Parsing and re-serialising changes the bytes and the HMAC will not match. This is the most common webhook bug there is. Verifying signatures has the code.
- Deduplicate on the envelope
id. Delivery is at-least-once; the sameevt_…can arrive more than once. The top-levelidis the dedupe key, notdata.event_id. - Fields with a null value are omitted entirely rather than sent as
null. Read defensively —data.prioritymay not be there at all. - Return 2xx fast, then work. The delivery times out at 10 seconds. A non-429 4xx stops retries immediately; 5xx, 429, and timeouts get four attempts over roughly a minute; 15 consecutive failed events disable the endpoint until an admin re-enables it.
The write path is narrow
ticket.updated pushes our state to you. The way back is PATCH /tickets/{ticket_id}, which can change status, priority, category, and tags, guarded by the ticket's version: send it as expected_version. That change sends a ticket.updated of its own, so ignore echoes of your own writes. There is no comment or reply endpoint, and no way to assign a ticket over the API. If your helpdesk is the system of record, mirror these tickets, push status changes back with PATCH, and do the conversation itself in the console.
Also expect a ping event. The Test button on an endpoint sends an ordinary envelope with "type": "ping", which is not one of the twenty subscribable event types. A default branch that acknowledges unknown types handles it, and handles any type added later. See the ping test event.
Failures #
invalid_argument`Provide at least one requester detail (name, email, or phone).` — all three requester fields were null.invalid_argumentRequest validation failed — an empty `subject` or `description`, a `subject` over 240 or `description` over 8000 characters, more than 10 tags, or a `priority` outside the four values. `details.errors` names the field.unauthenticatedKey missing, malformed, invalid, revoked, expired after a roll, or used against the wrong host. `details.reason` distinguishes all six.permission_deniedmissing_scopeThe key lacks `tickets:write` — required for creating *and* reading. `details.required_scope` names it.not_found`Ticket not found.` — unknown id, or a ticket belonging to another workspace.conflictidempotency_key_reuseThe same `Idempotency-Key` arrived with a different request body. Fix the key or the body. If the earlier request with this key succeeded, do not retry with a fresh key.conflictidempotency_in_progressAn earlier request with this key is still pending. Retry after `details.retry_after_seconds` (2). A request that returns an error releases its key, so this lasts only while the earlier one is running.permission_deniedworkspace_suspendedThe workspace is suspended for billing. Every request with the key is refused until billing is restored.rate_limitedrate_limited120 requests in one minute on this key. Back off to the next minute boundary; there is no Retry-After header.Retry 429 rate_limited and network errors with the same key and the same bytes. After a 5xx, the key is released, so retry with the same key and the same body: the body-hash dedupe still returns the existing ticket if the first request got as far as creating one. Do not retry 400, 403, 404, or 409 idempotency_key_reuse: the answer will not change, and an escalation stuck in a retry loop is an escalation nobody is working. See Idempotency.
Going to production #
- Alert on escalation volume, not on individual failures. A handler that suddenly files ten times as many tickets is a bug in your rules, and it looks like success from the API's side.
- Store
public_refagainst your own record the moment you get it. It is the only reference a customer can quote and the only one your support team can search for quickly. - Log
error.request_idon every failure. Developers → Logs keeps 7 days of the requests your keys made, with status, error code, and duration, but it doesn't show the request id, and requests refused before the key was accepted (a401or a rate-limit429) aren't listed. - Escalate once. Guard the handler with your own "already escalated" flag as well as the idempotency key. The key expires after 24 hours; your flag does not.
Next #
The endpoint reference
Tickets — every field, every bound, and the full deduplication contract.
Ticket events
ticket.created, ticket.updated and ticket.replied — the exact payloads and the other seventeen event types.
Idempotency
How keys are scoped, how long they live, and what a replayed response looks like.
Before you launch
Going live — the checklist this recipe assumes you have worked through.