Skip to content

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 ​

RequestDoes
POST /ticketsOpens a ticket. Needs an Idempotency-Key. Answers 201 with the ticket.
GET /ticketsLists 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}/messagesAdds 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}/resolveMarks it resolved. The body must be {}. Needs an Idempotency-Key. Resolving one that is already resolved is 422 invalid_status_transition.
POST /uploadsUploads one file and returns a token to attach it.
GET /attachments/{attachmentId}Downloads an attachment from one of your tickets.

Opening a ticket ​

FieldNotes
subject3 to 200 characters.
description1 to 10,000 characters. Becomes the first message.
categoryaddress_change, prescriber_change, script_change, shipping_issue, patient_inquiry or other (the default).
priorityP2 or P3 (the default).
order_idYour 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.
attachmentsUp 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 ​

  1. POST /uploads with the file's raw bytes as the body and its real Content-Type. Sign it like any request, with the raw bytes as the body. No Idempotency-Key.
  2. The answer is 201 with upload.token, upload.size_bytes and upload.expires_at. A token lasts 24 hours and can be used once, by your account in the same environment.
  3. Put the token in attachments when you open the ticket or reply.
LimitValue
File typesPDF, PNG, JPEG, WebP, GIF, HEIC/HEIF, Word (.docx), Excel (.xlsx, .xls), CSV, plain text, MP4, QuickTime, WebM
Size10 MB for documents and images, 25 MB for video
Uploads100 per hour per account
Per message4 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-sessions returns a short-lived embed_url (valid 15 minutes) to load in an iframe. Mint a fresh one each time the widget opens. No Idempotency-Key.
  • GET /support-sessions/domains lists 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.