Appearance
Support tickets
Summary: open a ticket with POST /tickets (optionally about one of your orders and with files attached), get told about replies through the ticket.message_added webhook, read the thread with GET /tickets/{id}, answer with POST /tickets/{id}/messages, and close it with POST /tickets/{id}/resolve. Ticketing is switched on per account: if every ticket endpoint answers 404 not_found, ask us to turn it on.
Sandbox keys and production keys each see only their own tickets, so you can try the whole flow in sandbox.
Endpoints
| Request | Does |
|---|---|
POST /tickets | Opens a ticket. Needs an Idempotency-Key. Answers 201 with the ticket. |
GET /tickets | Lists your tickets, filterable by status (open, in_progress, resolved), paged with limit (up to 100, default 50) and offset. |
GET /tickets/{ticketId} | The ticket, its messages (oldest first, the opening description first) and its attachments. |
POST /tickets/{ticketId}/messages | Adds your reply (message, up to 10,000 characters, and up to 4 attachments). Needs an Idempotency-Key. Replying to a resolved ticket reopens it. |
POST /tickets/{ticketId}/resolve | Marks it resolved. The body must be {}. Needs an Idempotency-Key. Resolving one that is already resolved is 422 invalid_status_transition. |
POST /uploads | Uploads one file and returns a token to attach it. |
GET /attachments/{attachmentId} | Downloads an attachment from one of your tickets. |
Opening a ticket
| Field | Notes |
|---|---|
subject | 3 to 200 characters. |
description | 1 to 10,000 characters. Becomes the first message. |
category | address_change, prescriber_change, script_change, shipping_issue, patient_inquiry or other (the default). |
priority | P2 or P3 (the default). |
order_id | Your partner_order_id for the order it is about. If we cannot find that order in this environment, the ticket is still opened, unlinked, and the response says "order_id_unresolved": true. |
attachments | Up to 4 upload tokens, each { "token": "...", "file_name": "..." }. |
Unknown fields are refused. Keep patient details out of the subject: put what we need in the description, and only what we need.
mjs
// support-ticket.mjs: upload a file, open a ticket about an order with it attached, then read it back.
import { createHmac, randomUUID } from "node:crypto";
import { axiom } from "./axiom-client.mjs";
// 1. Upload the file first. The body is the raw bytes; the response is a one-time token.
// (axiom() sends JSON, so the upload is signed by hand here: same recipe, raw bytes as the body.)
const bytes = Buffer.from("Label photo would go here.\n");
const path = `/api/inbound/${process.env.AXIOM_PARTNER_SLUG}/uploads`;
const ts = Math.floor(Date.now() / 1000).toString();
const sig = createHmac("sha256", process.env.AXIOM_SIGNING_SECRET)
.update(Buffer.concat([Buffer.from(`POST.${path}.${ts}.`), bytes]))
.digest("hex");
const upload = await fetch((process.env.AXIOM_BASE_URL ?? "https://api.axiomscript.com") + path, {
method: "POST",
headers: { "Content-Type": "text/plain", "X-Axiom-Timestamp": ts, "X-Axiom-Signature": `sha256=${sig}` },
body: bytes,
}).then((r) => r.json());
console.log("upload:", upload.upload.size_bytes, "bytes, token expires", upload.upload.expires_at);
// 2. Open the ticket, linked to one of your orders by your own order id.
const created = await axiom(
"POST",
"/tickets",
{
subject: "Package arrived damaged",
description: "The clinic reports the outer box was crushed. Photo attached.",
category: "shipping_issue",
order_id: "ACME-0001",
attachments: [{ token: upload.upload.token, file_name: "box.txt" }],
},
{ idempotencyKey: randomUUID() },
);
console.log("create:", created.status, created.body.ticket?.id, created.body.ticket?.status, created.body.order_id_unresolved ?? "");
// 3. Read it back: the thread and its attachments, oldest message first.
const ticket = await axiom("GET", `/tickets/${created.body.ticket.id}`);
console.log("get:", ticket.status, ticket.body.messages.length, "message(s),", ticket.body.attachments.length, "attachment(s)");Attachments
POST /uploadswith the file's raw bytes as the body and its realContent-Type. Sign it like any request, with the raw bytes as the body. NoIdempotency-Key.- The answer is
201withupload.token,upload.size_bytesandupload.expires_at. A token lasts 24 hours and can be used once, by your account in the same environment. - Put the token in
attachmentswhen you open the ticket or reply.
| Limit | Value |
|---|---|
| File types | PDF, PNG, JPEG, WebP, GIF, HEIC/HEIF, Word (.docx), Excel (.xlsx, .xls), CSV, plain text, MP4, QuickTime, WebM |
| Size | 10 MB for documents and images, 25 MB for video |
| Uploads | 100 per hour per account |
| Per message | 4 attachments |
We check the file's signature bytes against the declared type (Office files only as ZIP containers). Upload errors: 400 empty_file, 413 file_too_large or payload_too_large (usually with maxBytes), 415 unsupported_file_type, 415 file_content_mismatch, 503 upload_capacity (retry after the Retry-After seconds). Attaching a bad token is 400 invalid_upload_token or 400 duplicate_upload_token; more than 4 attachments is 400 validation_failed.
Downloads come back with the stored content type, Content-Disposition: attachment and Cache-Control: private, no-store.
Staying in sync
Subscribe a webhook endpoint to ticket.message_added and ticket.status_changed. The events say which ticket changed, its status and who wrote last (lastMessageFrom: axiom, partner, or null before the first reply), but not the text: fetch the ticket to read it. See Webhooks.
The support widget
You can embed our support widget in your own product. A widget session sees every support ticket on your account in that environment, with their files: open it only for people allowed to see all of your support threads (your own support staff), never for a clinic or a patient.
POST /support-sessionsreturns a short-livedembed_url(valid 15 minutes) to load in an iframe. Mint a fresh one each time the widget opens. NoIdempotency-Key.GET /support-sessions/domainslists the web origins allowed to frame the widget for your account. Pass?url=to check a specific page. You manage that list yourself on the Widget page of the partner portal.

