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

Source: https://docs.amomic.in/recipes/support-escalation

---

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.

::: note 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](/api/scopes).
:::

## The requester is not optional

A ticket with no requester is a ticket nobody can answer, so the route refuses one:

```json title="400 Bad Request"
{
  "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`.

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

```json title="you send"
{ "tags": ["refund", "escalated"] }
```

```json title="the ticket carries"
{ "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*:

```javascript
// 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.

::: tip 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](/api/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.

::: codegroup

```python Python
# escalate.py
import os
import requests

BASE_URL = "https://services.amomic.in/api/v1"
API_KEY = os.environ["AMOMIC_API_KEY"]        # needs tickets:write + conversations:read
HEADERS = {"Authorization": f"Bearer {API_KEY}"}

DESCRIPTION_LIMIT = 8000                       # hard API limit
SUBJECT_LIMIT = 240                            # hard API limit


class AmomicError(Exception):
    def __init__(self, status: int, error: dict):
        super().__init__(f"{error['code']}: {error['message']}")
        self.status = status
        self.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
```

```javascript Node.js
// escalate.js
const BASE_URL = "https://services.amomic.in/api/v1";
const HEADERS = { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` };
// The key needs tickets:write and conversations:read.

const DESCRIPTION_LIMIT = 8000;   // hard API limit
const SUBJECT_LIMIT = 240;        // hard API limit

class AmomicError extends Error {
  constructor(status, error) {
    super(`${error.code}: ${error.message}`);
    this.status = status;
    this.code = error.code;
    this.details = error.details ?? {};
    this.reason = error.details?.reason ?? null;
    this.requestId = error.request_id;
  }
}

async function json(response) {
  const payload = await response.json();
  if (!response.ok) throw new AmomicError(response.status, payload.error);
  return payload;
}

// At least one of name/email/phone must be present, so never return all-null.
function requesterFrom(conversation) {
  const customer = conversation.customer ?? {};
  let name = customer.name ?? customer.display_name ?? null;
  const email = customer.email ?? null;
  const phone = customer.phone ?? null;

  if (!name && !email && !phone) {
    // An anonymous web visitor. A synthesised name beats a 400.
    name = `Customer · ${conversation.conversation_id}`;
  }

  return {
    requester_name: name ? name.slice(0, 120) : null,
    requester_email: email ? email.slice(0, 200) : null,
    requester_phone: phone ? phone.slice(0, 32) : null,
  };
}

// Last few turns, oldest first. Roles: user (customer), model (agent), human (your team).
// Reads the first 200 turns; follow next_cursor for longer threads.
const LABELS = { user: "Customer", model: "Agent", human: "Team" };
async function recentTurns(conversationId, turns = 12) {
  const page = await json(
    await fetch(`${BASE_URL}/conversations/${conversationId}/messages?limit=200`, {
      headers: HEADERS,
    }),
  );
  return page.items
    .slice(-turns)
    .map((m) => `${LABELS[m.role] ?? "Agent"}: ${m.text ?? ""}`)
    .join("\n");
}

export async function escalate(conversationId, reason, { priority = "normal", tags = [] } = {}) {
  const conversation = await json(
    await fetch(`${BASE_URL}/conversations/${conversationId}`, { headers: HEADERS }),
  );

  const transcript = await recentTurns(conversationId);
  const description = [
    reason,
    "",
    `Channel: ${conversation.channel}`,
    `Conversation: ${conversationId}`,
    `Turns so far: ${conversation.message_count ?? 0}`,
    "",
    "--- recent transcript ---",
    transcript,
  ]
    .join("\n")
    .slice(0, DESCRIPTION_LIMIT);

  const ticket = await json(
    await fetch(`${BASE_URL}/tickets`, {
      method: "POST",
      headers: {
        ...HEADERS,
        "Content-Type": "application/json",
        // One escalation per conversation. Not a timestamp, not a UUID.
        "Idempotency-Key": `escalate-${conversationId}`,
      },
      body: JSON.stringify({
        subject: `Escalated: ${reason}`.slice(0, SUBJECT_LIMIT),
        description,
        priority,
        tags: tags.slice(0, 10),
        ...requesterFrom(conversation),
      }),
    }),
  );

  // public_ref is what you quote to the customer; ticket_id is your handle.
  await recordEscalation(conversationId, ticket.ticket_id, ticket.public_ref);
  return ticket;
}
```

```bash cURL
curl -X POST https://services.amomic.in/api/v1/tickets \
  -H "Authorization: Bearer $AMOMIC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: escalate-con_01JQRS4T8XZ0AB1CD2EF3GH4" \
  -d '{
    "subject": "Escalated: refund requested outside the 30-day window",
    "description": "Customer asked for a refund on AM-4821, delivered 41 days ago. Policy is 30 days, so the agent could not approve it. Customer says the item was faulty on arrival and they were travelling.\n\nChannel: whatsapp\nConversation: con_01JQRS4T8XZ0AB1CD2EF3GH4\nOrder: AM-4821\nAmount: ₹4,299",
    "priority": "high",
    "requester_name": "Asha Menon",
    "requester_email": "asha.menon@example.in",
    "requester_phone": "+919876543210",
    "tags": ["refund", "policy-exception"]
  }'
```

:::

Three details in that code doing real work:

- **Every string is truncated to the API limit before it is sent.** `subject` over 240 or `description` over 8000 is a `400`, and a transcript excerpt is exactly the kind of field that grows past a limit in production but not in your tests.
- **`_requester` never returns three nulls.** The `400` it 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.

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

```json title="201 Created"
{
  "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.

::: warning 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](/api/tickets) 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:

```bash
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](/channels/whatsapp-window) explains the rule; [Templates](/channels/whatsapp-templates) covers getting one approved.

## Reading the ticket back

```endpoint
GET /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`.

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

```json title="POST to your endpoint"
{
  "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](/webhooks/signatures) has the code.
- **Deduplicate on the envelope `id`.** Delivery is at-least-once; the same `evt_…` can arrive more than once. The top-level `id` is the dedupe key, not `data.event_id`.
- **Fields with a null value are omitted entirely** rather than sent as `null`. Read defensively — `data.priority` may 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.

::: warning The write path is narrow
`ticket.updated` pushes our state to you. The way back is [`PATCH /tickets/{ticket_id}`](/api/tickets), 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](/webhooks/events#the-ping-test-event).

## Failures

```responses
[
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "`Provide at least one requester detail (name, email, or phone).` — all three requester fields were null."},
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "Request 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."},
  {"status": 401, "code": "unauthenticated", "reason": "", "description": "Key missing, malformed, invalid, revoked, expired after a roll, or used against the wrong host. `details.reason` distinguishes all six."},
  {"status": 403, "code": "permission_denied", "reason": "missing_scope", "description": "The key lacks `tickets:write` — required for creating *and* reading. `details.required_scope` names it."},
  {"status": 404, "code": "not_found", "reason": "", "description": "`Ticket not found.` — unknown id, or a ticket belonging to another workspace."},
  {"status": 409, "code": "conflict", "reason": "idempotency_key_reuse", "description": "The 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."},
  {"status": 409, "code": "conflict", "reason": "idempotency_in_progress", "description": "An 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."},
  {"status": 403, "code": "permission_denied", "reason": "workspace_suspended", "description": "The workspace is suspended for billing. Every request with the key is refused until billing is restored."},
  {"status": 429, "code": "rate_limited", "reason": "rate_limited", "description": "120 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](/api/idempotency#after-an-error-response-retry-with-the-same-key).

## 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_ref` against 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_id` on 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 (a `401` or a rate-limit `429`) 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

::: cards

::: card The endpoint reference
[Tickets](/api/tickets) — every field, every bound, and the full deduplication contract.
:::

::: card Ticket events
[`ticket.created`, `ticket.updated` and `ticket.replied`](/webhooks/events) — the exact payloads and the other seventeen event types.
:::

::: card Idempotency
[How keys are scoped](/api/idempotency), how long they live, and what a replayed response looks like.
:::

::: card Before you launch
[Going live](/operate/going-live) — the checklist this recipe assumes you have worked through.
:::

:::
