# Tickets

> Open, read, and update a support ticket from your own systems with safe retries and optimistic version checks.

Source: https://docs.amomic.in/api/tickets

---

```endpoint
POST /api/v1/tickets
```

A ticket is work for a human. These endpoints create one from your side of the wall, give you back a reference you can quote to the customer, and let your system move it along.

## When you use this

Two situations, mostly. Something in **your** system failed in a way that needs a person: a payment captured with no order behind it, a technician visit your scheduler couldn't place, a warranty claim with a serial number that won't validate. Or your own product has a "contact support" form and you want it to land in the same queue your agents feed, rather than in a shared mailbox nobody owns.

What does *not* need this endpoint: your agent opening a ticket mid-conversation. That already happens on its own when a customer asks for a human or hits something the agent can't resolve. Use the API for tickets that start in your systems.

| Method | Path | Scope | Purpose |
|---|---|---|---|
| `POST` | `/api/v1/tickets` | `tickets:write` | Create a ticket. |
| `GET` | `/api/v1/tickets/{ticket_id}` | `tickets:write` | Read one back. |
| `PATCH` | `/api/v1/tickets/{ticket_id}` | `tickets:write` | Change status, priority, category, or tags. |

::: warning Reading a ticket needs `tickets:write`
There is no `tickets:read` scope. `GET /tickets/{ticket_id}` needs `tickets:write`, the same scope that creates and updates them. A key that only looks tickets up still has to be able to create them, so keep it on a server you trust. See [Scopes](/api/scopes).
:::

There is no list endpoint and no endpoint for the conversation on a ticket. Replies, internal notes and assignment are handled in the console; see [Tickets](/console/tickets).

## Parameters

```params
[
  {"name": "subject", "type": "string", "required": true, "description": "One-line summary, 1–240 characters. This is what your team sees in the queue, so make it specific enough to triage without opening."},
  {"name": "description", "type": "string", "required": true, "description": "The full detail, 1–8,000 characters. Becomes the first message on the ticket. Include the identifiers your team will need: order ids, serial numbers, transaction references."},
  {"name": "priority", "type": "string", "required": false, "default": "normal", "enum": ["low", "normal", "high", "urgent"], "description": "Sets the response clock. See the SLA table below."},
  {"name": "requester_name", "type": "string", "required": false, "default": "null", "description": "Up to 120 characters. At least one of name, email, or phone is required."},
  {"name": "requester_email", "type": "string", "required": false, "default": "null", "description": "Up to 200 characters."},
  {"name": "requester_phone", "type": "string", "required": false, "default": "null", "description": "Up to 32 characters. Send E.164."},
  {"name": "tags", "type": "string[]", "required": false, "default": "[]", "description": "Up to 10. Lower-cased, spaces become hyphens, each cut to 32 characters, duplicates dropped. The server adds <code>api</code>."}
]
```

### At least one requester detail

A ticket with no requester is a ticket nobody can answer. The route checks this before anything is written:

```json title="400 Bad Request"
{
  "error": {
    "code": "invalid_argument",
    "message": "Provide at least one requester detail (name, email, or phone).",
    "details": {},
    "request_id": "req_01JRB8A4C7E1G4J7M0Q3T6W9ZB",
    "correlation_id": "cor_01JRB8A4C7E1G4J7M0Q3T6W9ZB"
  }
}
```

Any one of the three satisfies the rule. In practice send everything you have: a name tells your team who to look up, an email or phone tells them how to reach the person.

This error carries no `details.reason`. Branch on `error.code` here.

### Priority sets a clock

`sla_due_at` is computed from `priority` when the ticket is created:

| `priority` | `sla_due_at` |
|---|---|
| `urgent` | 2 hours after creation |
| `high` | 8 hours after creation |
| `normal` | 24 hours after creation |
| `low` | 72 hours after creation |

The clock is set **once**. Changing the priority later, in the console or with `PATCH`, doesn't recompute `sla_due_at`. Send the right priority from your system, where you know how bad the failure is.

### Tags, and the one you didn't send

Your tags come back normalised, with `api` added:

```json title="sent"
{ "tags": ["Warranty Claim", "split-ac"] }
```

```json title="on the created ticket"
{ "tags": ["warranty-claim", "split-ac", "api"] }
```

The `api` tag is how your team tells machine-created tickets from ones an agent or a teammate opened. It's useful the first time your integration misfires and files forty tickets at 2 a.m.

### What happens when it's created

An API ticket goes through the same path as one the agent opens:

- it starts as `new`, with `sla_due_at` set;
- it's auto-assigned if your [ticket settings](/console/ticket-settings) assign new tickets;
- AI triage reads it and attaches its notes for your team. That is a change to the ticket, which is why the create response usually already has `version` 2;
- `ticket.created` fires.

## Example

::: codegroup

```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: warranty-VH-AC-2291847" \
  -d '{
    "subject": "Warranty claim rejected by serial check: VH-AC-2291847",
    "description": "Customer registered a 1.5 ton split AC on 3 Oct. Our warranty service rejects serial VH-AC-2291847 as unknown, so the free gas refill visit cannot be booked.\n\nInvoice: VH-INV-118402\nPurchased: Veloria Home store, Andheri West",
    "priority": "high",
    "requester_name": "Arjun Malhotra",
    "requester_email": "arjun.malhotra@example.in",
    "requester_phone": "+919811047352",
    "tags": ["warranty-claim", "split-ac"]
  }'
```

```javascript Node.js
const BASE = "https://services.amomic.in/api/v1";

async function openWarrantyTicket(claim) {
  const response = await fetch(`${BASE}/tickets`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `warranty-${claim.serial}`,
    },
    body: JSON.stringify({
      subject: `Warranty claim rejected by serial check: ${claim.serial}`,
      description: claim.detail,
      priority: "high",
      requester_name: claim.customerName,
      requester_email: claim.customerEmail,
      requester_phone: claim.customerPhone,
      tags: ["warranty-claim", "split-ac"],
    }),
  });

  const payload = await response.json();
  if (!response.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);

  // Quote public_ref to the customer; keep ticket_id and version for later calls.
  return { ref: payload.public_ref, id: payload.ticket_id, version: payload.version };
}
```

```python Python
import os

import requests

BASE = "https://services.amomic.in/api/v1"


def open_warranty_ticket(claim) -> dict:
    response = requests.post(
        f"{BASE}/tickets",
        headers={
            "Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}",
            "Idempotency-Key": f"warranty-{claim.serial}",
        },
        json={
            "subject": f"Warranty claim rejected by serial check: {claim.serial}",
            "description": claim.detail,
            "priority": "high",
            "requester_name": claim.customer_name,
            "requester_email": claim.customer_email,
            "requester_phone": claim.customer_phone,
            "tags": ["warranty-claim", "split-ac"],
        },
        timeout=30,
    )
    if not response.ok:
        error = response.json()["error"]
        raise RuntimeError(f"{error['code']}: {error['message']}")

    ticket = response.json()
    return {"ref": ticket["public_ref"], "id": ticket["ticket_id"], "version": ticket["version"]}
```

:::

## Response

`201 Created`, with the ticket record:

```json title="201 Created (trimmed)"
{
  "ticket_id": "tkt_6b1f0e93a27c48d5e1f04a9c",
  "public_ref": "AM-7C2E94B1",
  "subject": "Warranty claim rejected by serial check: VH-AC-2291847",
  "status": "new",
  "priority": "high",
  "category": null,
  "tags": ["warranty-claim", "split-ac", "api"],
  "requester": {
    "name": "Arjun Malhotra",
    "email": "arjun.malhotra@example.in",
    "phone": "+919811047352",
    "external_id": null
  },
  "source": { "channel": "api", "thread_id": "api:key_8d41c2…:4f7a19…", "message_id": null },
  "assignee_id": "usr_01JQ4K7N2P5S8V1Y4B7E0H3K6M",
  "assignee_name": "Neha Kulkarni",
  "version": 2,
  "message_count": 1,
  "sla_due_at": "2026-10-14T13:52:07.418903+00:00",
  "created_at": "2026-10-14T05:52:07.418903+00:00",
  "updated_at": "2026-10-14T05:52:09.770126+00:00"
}
```

The fields you can rely on:

| Field | Type | Notes |
|---|---|---|
| `ticket_id` | string | `tkt_` followed by 24 hex characters. The id for `GET` and `PATCH`. |
| `public_ref` | string | `AM-` followed by 8 uppercase hex characters. The reference to quote to a customer. |
| `subject` | string | As you sent it. |
| `status` | string | `new` on creation. See [Updating a ticket](#updating-a-ticket) for the others. |
| `priority` | string | `low`, `normal`, `high` or `urgent`. |
| `category` | string \| null | Set by your team, or with `PATCH`. |
| `tags` | string[] | Your tags, normalised, plus `api`. |
| `requester` | object | `name`, `email`, `phone` and `external_id`. Each key is present; the ones you didn't send are `null`. |
| `assignee_id`, `assignee_name` | string \| null | Set when the ticket was auto-assigned or claimed. |
| `version` | integer | Goes up by one on every change. `PATCH` needs the current value. |
| `sla_due_at` | string \| null | ISO 8601 with offset. Set from `priority` at creation. |
| `created_at`, `updated_at` | string \| null | ISO 8601 with offset, for example `2026-10-14T05:52:07.418903+00:00`. |

::: note Other fields are present
The body is the ticket record, and it holds more than the table: AI triage output, escalation and status-change timestamps, and internal bookkeeping. Those aren't part of the API contract and can change. Read the fields in the table, ignore the rest, and don't use a strict validator that rejects unknown keys.
:::

### Two identifiers, two audiences

`ticket_id` (`tkt_…`) is the machine handle. Store it next to your own record.

`public_ref` (`AM-XXXXXXXX`) is the human handle, short enough to read out over the phone. Put it in your confirmation email and your own UI. Both are stable for the life of the ticket, and both appear on the `ticket.created` and `ticket.updated` [webhooks](/webhooks/events).

## Reading a ticket back

```endpoint
GET /api/v1/tickets/{ticket_id}
```

Scope `tickets:write`. No query parameters. Returns the same record as the create response, as it is now.

::: codegroup

```bash cURL
curl https://services.amomic.in/api/v1/tickets/tkt_6b1f0e93a27c48d5e1f04a9c \
  -H "Authorization: Bearer $AMOMIC_API_KEY"
```

```javascript Node.js
const response = await fetch(
  "https://services.amomic.in/api/v1/tickets/tkt_6b1f0e93a27c48d5e1f04a9c",
  { headers: { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` } },
);
const ticket = await response.json();
console.log(ticket.status, ticket.version);
```

```python Python
import os

import requests

response = requests.get(
    "https://services.amomic.in/api/v1/tickets/tkt_6b1f0e93a27c48d5e1f04a9c",
    headers={"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"},
    timeout=15,
)
ticket = response.json()
print(ticket["status"], ticket["version"])
```

:::

The conversation on the ticket (staff replies, customer messages, internal notes) is **not** included. An unknown id, or one from another workspace, returns `404 not_found` with `Ticket not found.`

To react to progress, prefer the `ticket.updated` webhook over polling. It carries `ticket_id`, `public_ref`, `subject`, `status`, `priority` and `channel`, which is usually everything a status sync needs.

## Updating a ticket

```endpoint
PATCH /api/v1/tickets/{ticket_id}
```

Scope `tickets:write`. Send the ticket's current `version` as `expected_version`, plus only the fields you want to change.

```params
[
  {"name": "expected_version", "type": "integer", "required": true, "description": "The <code>version</code> you last read. 1 or more. A mismatch is a <code>409</code>, and nothing changes."},
  {"name": "status", "type": "string", "required": false, "enum": ["new", "open", "pending", "waiting_customer", "resolved", "closed"], "description": "Only moves the status table below allows."},
  {"name": "priority", "type": "string", "required": false, "enum": ["low", "normal", "high", "urgent"], "description": "Doesn't recompute <code>sla_due_at</code>."},
  {"name": "category", "type": "string", "required": false, "description": "Up to 80 characters. Send <code>null</code> to clear it."},
  {"name": "tags", "type": "string[]", "required": false, "description": "Up to 20. <strong>Replaces</strong> the ticket's tags, so include <code>api</code> if you want to keep it. Normalised like the create."}
]
```

Leave out a field to leave it alone. Don't send `null` for `status`, `priority` or `tags`; those can't be cleared.

### Status moves

| From | Can move to |
|---|---|
| `new` | `open`, `pending`, `waiting_customer`, `resolved`, `closed` |
| `open` | `pending`, `waiting_customer`, `resolved`, `closed` |
| `pending` | `open`, `waiting_customer`, `resolved`, `closed` |
| `waiting_customer` | `open`, `pending`, `resolved`, `closed` |
| `resolved` | `open`, `closed` |
| `closed` | `open` |

Nothing moves back to `new`. A move the table doesn't allow is a `400` with `Cannot move a ticket from closed to resolved.` Resolving or closing a ticket also clears an open escalation, and a staff reply still waiting for the customer to open it is not delivered.

::: codegroup

```bash cURL
curl -X PATCH https://services.amomic.in/api/v1/tickets/tkt_6b1f0e93a27c48d5e1f04a9c \
  -H "Authorization: Bearer $AMOMIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expected_version": 4, "status": "resolved", "category": "warranty"}'
```

```javascript Node.js
async function resolveTicket(ticketId) {
  const url = `https://services.amomic.in/api/v1/tickets/${ticketId}`;
  const headers = { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` };

  for (let attempt = 0; attempt < 3; attempt++) {
    const current = await (await fetch(url, { headers })).json();
    if (current.status === "resolved" || current.status === "closed") return current;

    const response = await fetch(url, {
      method: "PATCH",
      headers: { ...headers, "Content-Type": "application/json" },
      body: JSON.stringify({ expected_version: current.version, status: "resolved" }),
    });
    if (response.ok) return response.json();
    if (response.status !== 409) {
      const { error } = await response.json();
      throw new Error(`${error.code}: ${error.message}`);
    }
    // 409: someone changed it first. Read it again and decide again.
  }
  throw new Error("Ticket kept changing; leave it to your team.");
}
```

```python Python
import os

import requests

BASE = "https://services.amomic.in/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"}


def resolve_ticket(ticket_id: str) -> dict:
    for _ in range(3):
        current = requests.get(f"{BASE}/tickets/{ticket_id}", headers=HEADERS, timeout=15).json()
        if current["status"] in ("resolved", "closed"):
            return current
        response = requests.patch(
            f"{BASE}/tickets/{ticket_id}",
            headers=HEADERS,
            json={"expected_version": current["version"], "status": "resolved"},
            timeout=15,
        )
        if response.ok:
            return response.json()
        if response.status_code != 409:
            error = response.json()["error"]
            raise RuntimeError(f"{error['code']}: {error['message']}")
        # 409: someone changed it first. Read it again and decide again.
    raise RuntimeError("Ticket kept changing; leave it to your team.")
```

:::

A successful update returns `200` with the updated record, its `version` one higher, and fires `ticket.updated`. A `PATCH` that changes nothing returns the ticket as it is.

### When someone changed it first

```json title="409 Conflict"
{
  "error": {
    "code": "conflict",
    "message": "Ticket changed since it was loaded.",
    "details": { "expected_version": 4, "current_version": 5 },
    "request_id": "req_01JRB9C2E5G8J1L4N7Q0S3V6XY",
    "correlation_id": "cor_01JRB9C2E5G8J1L4N7Q0S3V6XY"
  }
}
```

There is no `details.reason` on this one; branch on the `409`. Read the ticket again, decide whether your change still makes sense (a teammate may have just resolved it), and retry with `current_version`. This is what stops your integration silently overwriting a person's update.

The change is recorded against your API key rather than a person.

## Deduplication

Creating a ticket deduplicates twice over. Knowing which mechanism does the work saves you a confusing afternoon.

**On the request body, always.** The ticket id is derived from a hash of the raw request body. Send a byte-identical body again, with or without a key, and you get the same ticket back with `201`. That holds for as long as the ticket exists, even after it's closed: the same body a month later returns the old, closed ticket rather than opening a new one.

**With an `Idempotency-Key` header, for 24 hours.** The first response is stored and replayed for the same key, scoped to your workspace, your API key and this endpoint. The same key with a **different** body is a `409` with `idempotency_key_reuse`, not a second ticket. See [Idempotency](/api/idempotency).

What that means in practice:

```javascript
// Retried three times by your job runner after a timeout.
// Byte-identical body each time → one ticket, returned three times.
await createTicket({ subject, description, priority: "high", requester_email: email });

// Same incident, description regenerated with a new timestamp inside it.
// Different bytes, no key → THREE tickets.
// Different bytes, same Idempotency-Key → one ticket, then 409 idempotency_key_reuse twice.
await createTicket({ subject, description: `${detail} (checked ${new Date()})`, ... });
```

::: tip Build the body once per incident
Build the request body once, when the incident is detected, store it, and resend the stored bytes on every retry. Use an `Idempotency-Key` that names the incident, such as `warranty-VH-AC-2291847`. If the same kind of failure can happen again later and needs a new ticket, put something unique to that occurrence, like the date, in the subject or description.
:::

## Failures

```responses
[
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "`Provide at least one requester detail (name, email, or phone).` No name, email, or phone was supplied."},
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "`Request validation failed.` An empty or too-long `subject` or `description`, more than 10 tags on create or 20 on update, a `priority` or `status` outside the listed values, or a missing `expected_version`. `details.errors` names the field."},
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "`Cannot move a ticket from … to ….` The status move isn't allowed. See the status table."},
  {"status": 401, "code": "unauthenticated", "reason": "", "description": "The key is missing, malformed, unknown, revoked, past its roll grace period, or for the wrong environment. `details.reason` says which."},
  {"status": 403, "code": "permission_denied", "reason": "missing_scope", "description": "The key lacks `tickets:write`, needed for creating, reading and updating."},
  {"status": 403, "code": "permission_denied", "reason": "workspace_suspended", "description": "The workspace is suspended. Restore billing to use its keys."},
  {"status": 404, "code": "not_found", "reason": "", "description": "`Ticket not found.` Unknown id, or a ticket from another workspace."},
  {"status": 409, "code": "conflict", "reason": "", "description": "`Ticket changed since it was loaded.` `PATCH` with a stale `expected_version`. `details.current_version` has the new one."},
  {"status": 409, "code": "conflict", "reason": "idempotency_key_reuse", "description": "The same `Idempotency-Key` was used with a different request body."},
  {"status": 409, "code": "conflict", "reason": "idempotency_in_progress", "description": "The first request with this key is still running. Wait `details.retry_after_seconds` (2) and retry."},
  {"status": 412, "code": "failed_precondition", "reason": "unsupported_resource", "description": "Tickets aren't available on this deployment. Not transient; don't retry."},
  {"status": 429, "code": "rate_limited", "reason": "rate_limited", "description": "120 requests per minute per key. See [Rate limits](/api/rate-limits)."}
]
```

## Related

::: cards

::: card Escalate to a ticket
[The complete recipe](/recipes/support-escalation): deciding when to open one, and closing the loop with the `ticket.updated` webhook.
:::

::: card Ticket events
[`ticket.created`, `ticket.updated` and `ticket.replied`](/webhooks/events) push changes to you instead of you polling.
:::

::: card Tickets in the console
[Work the queue](/console/tickets): reply, assign, escalate and resolve.
:::

::: card Errors
[Every code and reason](/api/errors) in one place.
:::

:::
