Tickets
Open, read, and update a support ticket from your own systems with safe retries and optimistic version checks.
/api/v1/ticketsA 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 #
subjectstringrequiredOne-line summary, 1–240 characters. This is what your team sees in the queue, so make it specific enough to triage without opening.
descriptionstringrequiredThe 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 normalSets the response clock. See the SLA table below.
lownormalhighurgentrequester_namestringoptionaldefault nullUp to 120 characters. At least one of name, email, or phone is required.
requester_emailstringoptionaldefault nullUp to 200 characters.
requester_phonestringoptionaldefault nullUp 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:
{"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:
{ "tags": ["Warranty Claim", "split-ac"] }
{ "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, withsla_due_atset; - 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
version2; ticket.createdfires.
Example #
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:
{"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 #
/api/v1/tickets/{ticket_id}Scope tickets:write. No query parameters. Returns the same record as the create response, as it is now.
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 #
/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_versionintegerrequiredThe version you last read. 1 or more. A mismatch is a 409, and nothing changes.
statusstringoptionalOnly moves the status table below allows.
newopenpendingwaiting_customerresolvedclosedprioritystringoptionalDoesn't recompute sla_due_at.
lownormalhighurgentcategorystringoptionalUp to 80 characters. Send null to clear it.
tagsstring[]optionalUp 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.
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 #
{"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:
// 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 #
invalid_argument`Provide at least one requester detail (name, email, or phone).` No name, email, or phone was supplied.invalid_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.invalid_argument`Cannot move a ticket from … to ….` The status move isn't allowed. See the status table.unauthenticatedThe key is missing, malformed, unknown, revoked, past its roll grace period, or for the wrong environment. `details.reason` says which.permission_deniedmissing_scopeThe key lacks `tickets:write`, needed for creating, reading and updating.permission_deniedworkspace_suspendedThe workspace is suspended. Restore billing to use its keys.not_found`Ticket not found.` Unknown id, or a ticket from another workspace.conflict`Ticket changed since it was loaded.` `PATCH` with a stale `expected_version`. `details.current_version` has the new one.conflictidempotency_key_reuseThe same `Idempotency-Key` was used with a different request body.conflictidempotency_in_progressThe first request with this key is still running. Wait `details.retry_after_seconds` (2) and retry.failed_preconditionunsupported_resourceTickets aren't available on this deployment. Not transient; don't retry.rate_limitedrate_limited120 requests per minute per key. See [Rate limits](/api/rate-limits).Related #
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.