Skip to content

Tickets

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

Open Tickets
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.

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.

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.

Parameters #

subjectstringrequired

One-line summary, 1–240 characters. This is what your team sees in the queue, so make it specific enough to triage without opening.

descriptionstringrequired

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.

prioritystringoptionaldefault normal

Sets the response clock. See the SLA table below.

lownormalhighurgent
requester_namestringoptionaldefault null

Up to 120 characters. At least one of name, email, or phone is required.

requester_emailstringoptionaldefault null

Up to 200 characters.

requester_phonestringoptionaldefault null

Up to 32 characters. Send E.164.

tagsstring[]optionaldefault []

Up to 10. Lower-cased, spaces become hyphens, each cut to 32 characters, duplicates dropped. The server adds api.

At least one requester detail #

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

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:

sent
{ "tags": ["Warranty Claim", "split-ac"] }
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 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 #

Shell
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"]
}'

Response #

201 Created, with the ticket record:

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

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.

Reading a ticket back #

GET/api/v1/tickets/{ticket_id}

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

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

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 #

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.

expected_versionintegerrequired

The version you last read. 1 or more. A mismatch is a 409, and nothing changes.

statusstringoptional

Only moves the status table below allows.

newopenpendingwaiting_customerresolvedclosed
prioritystringoptional

Doesn't recompute sla_due_at.

lownormalhighurgent
categorystringoptional

Up to 80 characters. Send null to clear it.

tagsstring[]optional

Up to 20. Replaces the ticket's tags, so include api 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.

Shell
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"}'

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 #

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.

What that means in practice:

Node.js
// 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()})`, ... });

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 #

400invalid_argument`Provide at least one requester detail (name, email, or phone).` No name, email, or phone was supplied.
400invalid_argument`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.
400invalid_argument`Cannot move a ticket from … to ….` The status move isn't allowed. See the status table.
401unauthenticatedThe key is missing, malformed, unknown, revoked, past its roll grace period, or for the wrong environment. `details.reason` says which.
403permission_deniedmissing_scopeThe key lacks `tickets:write`, needed for creating, reading and updating.
403permission_deniedworkspace_suspendedThe workspace is suspended. Restore billing to use its keys.
404not_found`Ticket not found.` Unknown id, or a ticket from another workspace.
409conflict`Ticket changed since it was loaded.` `PATCH` with a stale `expected_version`. `details.current_version` has the new one.
409conflictidempotency_key_reuseThe same `Idempotency-Key` was used with a different request body.
409conflictidempotency_in_progressThe first request with this key is still running. Wait `details.retry_after_seconds` (2) and retry.
412failed_preconditionunsupported_resourceTickets aren't available on this deployment. Not transient; don't retry.
429rate_limitedrate_limited120 requests per minute per key. See [Rate limits](/api/rate-limits).

Escalate to a ticket

The complete recipe: deciding when to open one, and closing the loop with the ticket.updated webhook.

Ticket events

ticket.created, ticket.updated and ticket.replied push changes to you instead of you polling.

Tickets in the console

Work the queue: reply, assign, escalate and resolve.

Errors

Every code and reason in one place.