# CowTicket — integration guide for AI agents You are integrating an event website (the "storefront") with CowTicket, a headless ticketing backend. The storefront owns all event content (dates, venue, descriptions, design). CowTicket owns commerce: ticket availability, orders, Stripe payment, ticket issuance and check-in. Base URL: https://api.cow-ticket.dev All prices/amounts are in satang (Thai Baht × 100). Currency is THB only. ## Credentials (ask the user for these — never invent them) 1. accountId — the user's CowTicket account id. 2. An API key — created in the CowTicket app under API Keys (shown once at creation). Every API key has a type ("customer", "organizer" or "admin") and a set of permission scopes, both fixed when the key is created. Selling (the flow below) needs a customer token; managing events and ticket options needs an admin-type key carrying the "events:manage" scope — see "Managing events and ticket options". The key-creation form calls these "Storefront" (a customer-type key, sell-only) and "Management" (an admin-type key with chosen scopes) — if the integration only sells tickets, tell the user a Storefront key is enough. Ask the user which kind of key they handed you; a key cannot be upgraded afterwards. Keep the API key server-side only (environment variables). Never expose the API key or the JWT below to browser code; proxy CowTicket calls through the storefront's backend. CORS only allows the CowTicket app origin, so browser fetch would fail anyway. ## Authentication Exchange the API key for a 1-day JWT, cache it (off the returned "exp"), refresh on 401: POST /accounts/token header api-key: body { "accountId": "", "type": "customer" } → 200 { "token": "", "exp": "..." } "type" must match the key's own type, with one exception: an admin-type key may ask for "admin", "customer" or "organizer" tokens, so a single admin key can both sell and manage. Asking for a type the key doesn't cover returns 401. Send the JWT on every other request: authorization: Bearer The token is account-scoped: every request that names an accountId must name the same one as the token, or it returns 401. There is no per-buyer identity anywhere in the API. ## Endpoint reference ### List what's on sale GET /events/ticketConfigs?accountId=&eventCode= → { "data": [ { "id", "eventCode", "code", "name", "group", "price", "currency", "available", "limitPerOrder", "startSellingDate", "endSellingDate", "sessionStartAt", "sessionEndAt", "sessionDate" } ], "total": n, "refundPolicy": { "allowed": bool, "termEn": str, "termTh": str }, "paymentMethods": ["STRIPE"], "purchaseForm": [ { "key", "type", "required", "labelTh", "labelEn", "options"?, "auto"? } ] } eventCode is unique inside your account, not across the platform, so this call needs accountId too — omit it and the account behind your token is used. Only ACTIVE ticket types inside their selling window are returned. "available" is a live count. Show refundPolicy.termEn / termTh near the checkout button (pick by the storefront's language; show both if bilingual). An empty "data" means nothing is on sale right now — before the selling window, after it, or the organizer archived the event — so render a "not on sale" state, never an error. "purchaseForm" is what the organizer wants asked at checkout — render exactly these fields, in this order, instead of inventing your own. Each has a "key" (where the answer goes), a "type" ("text" | "tel" | "email" | "select"; "select" carries "options"), "required", and both "labelTh" and "labelEn" — pick the label by your reader's language. Send the answers as "metadata" on POST /orders, keyed by "key". One key is special: "principal" is the attendee name, so send that one as passDisplay.principal too (see below); even if you only put it in "metadata", it still becomes each ticket's holder name (what gate staff search by). An answer to an "email"-type field is likewise lifted onto the order itself when no top-level "email" was sent, so the buyer still gets the confirmation email and support can look the order up by address. Most events have no form configured; the resolved default depends on payment methods — an event selling by card gets one required email field (Stripe needs a receipt address), an event selling only through chat gets no questions at all. A field marked "auto": true was added by the platform rather than the organizer — render it exactly like any other field. "paymentMethods" lists the checkout paths the organizer enabled, and it decides which buttons you render: - "STRIPE" → your normal card checkout (POST /orders below). - "PROMPTPAY" → chat checkout: a "ซื้อผ่าน LINE" button that starts a LINE conversation (POST /orders/intent below). No card form, no email field — the buyer completes everything in chat. An event may list both (render both buttons) or either alone. Treat an unrecognised value as a method you do not support yet and simply do not render a button for it, rather than failing. Read it fresh with the ticket options — do not cache it or bake buttons in at build time. The organizer can add AND remove a channel at any time from their settings page, so a method that was there yesterday may be gone today, and posting to a removed channel returns an error. Orders already placed on a removed channel are unaffected and still pay normally. "data" arrives in the organizer's chosen display order — render it in this order, do not re-sort. Two optional fields section that list, on independent axes: "sessionDate" the calendar day (YYYY-MM-DD, Asia/Bangkok) this ticket admits you on, or null. Derived from "sessionStartAt" — use this rather than your own timezone maths, so your sections match the organizer's portal exactly. "group" a free-text section label the organizer typed, or null. Used for anything that is not time: zone, tier, add-ons, packages. Rendering rule: iterate in order; start a new outer section whenever "sessionDate" changes, and a new inner section whenever "group" changes. Items with both null are ungrouped — render them flat, before any section, with no heading. If every item has both null, render one flat list (most events). Headings: for a "group" section, use the text verbatim. For a "sessionDate" section, format "sessionStartAt" yourself in your storefront's language ("รอบ 7 ธ.ค." / "Round 7 Dec") — the API deliberately sends no human-readable session label, because only you know your reader's locale. Sections are display structure only: items in the same section share no behavior, and buyers can still mix items across sections in one order. "sessionStartAt"/"sessionEndAt" (ISO 8601, or null) are when the ticket ADMITS you — not when it is on sale, which is "startSellingDate"/"endSellingDate". Show the session on the ticket and in your order confirmation whenever it is set. For a multi-round event it is the only thing telling two otherwise identical tickets apart, and a buyer who reads "10:00–10:30" with no date can arrive on the wrong day and be turned away at the gate. ### Create an order POST /orders { "accountId": "", "eventCode": "", "email": "", "items": [ { "code": "", "quantity": n, "passDisplay": { ... } } ], // 1–20 items "metadata": { ...any buyer form answers... }, // optional "returnUrl": "", // optional "theme": { "primaryColor": "#16a34a", // optional "secondaryColor": "#111827" } } → { "orderId", "orderNo", "accessToken", "status", "expiresAt", "checkoutUrl", "ticketUrl" } - **Redirect to "checkoutUrl" if it is set, otherwise to "ticketUrl".** That one line is the whole post-order step and handles both cases: - Paid order → status PENDING, checkoutUrl set (Stripe Checkout). Stripe returns the buyer to "ticketUrl" itself once they pay. - Free order → status PAID, checkoutUrl **null**, and nothing to pay. "ticketUrl" is where the buyer goes right now. - Do not assemble "ticketUrl" yourself from orderId and accessToken. Use the value in the response; the shape of that URL is ours and may change. - Inventory is reserved atomically; unpaid orders expire after 1 hour and release seats. - metadata keys/values appear in the organizer's participant exports — put attendee form answers (phone, shirt size, ...) here. Keep it a flat object of scalars. - "returnUrl" is your link BACK from the ticket page — rendered as "กลับไปที่ร้าน" in its top-left, and where a cancelled Stripe checkout returns to. Send the page the buyer came from (the event page) rather than your home page: it is a shopping trip, and this is the one place after checkout where you still get to continue it. It may contain {ORDER_ID} and {ORDER_TOKEN}, though it rarely needs to. - "accessToken" is a per-order secret. Keep it out of analytics, client-side error reporting and cross-origin Referer headers, exactly as you would the order id. - There is no "successUrl" or "cancelUrl". They were removed with the ticket-page boundary: sending them is simply ignored. Buyers always land on the CowTicket ticket page, and a cancelled checkout goes to your "returnUrl". - passDisplay (optional, per item) carries display text onto each issued ticket: { "main", "description", "head", "date", "dateDisplay", "principal", "zone", "seat" }. Always send "principal" (the attendee name) — it is what gate staff search by when a QR won't scan, and it comes back on each ticket in /orders/detail. Omitted fields fall back to defaults (event name, ticket type name, order date, buyer email). ### Chat checkout — start a LINE purchase POST /orders/intent { "accountId": "", "eventCode": "", "items": [ { "code": "", "quantity": n, "passDisplay": { ... } } ], // 1–20 items "metadata": { ...answers already collected on your form... }, // optional "returnUrl": "", // optional "theme": { "primaryColor": "#16a34a", // optional "secondaryColor": "#111827" } } → { "intentCode": "CTI-XXXXXX", "lineUrl": "https://line.me/R/oaMessage/..." } Called INSTEAD of POST /orders when the buyer taps the LINE button. Then simply send the buyer to "lineUrl" (location.href works on mobile) — it opens LINE with a message pre-filled; they press send and the rest of the purchase happens in the chat: any missing required purchaseForm answers, the PromptPay QR, the payment slip, and ticket delivery. Notes: - **No "email" and no "successUrl"/"cancelUrl"** — the buyer fills in nothing on the web, and there is no redirect to cancel from. That is the point of this flow. - **Nothing is reserved yet.** An intent holds no inventory and never expires; the 1-hour hold starts only when the order is created in chat. Do not show a countdown. - If your checkout already collected purchaseForm answers, pass them in "metadata" (and "principal" also as passDisplay.principal) — the chat then skips those questions. Anything required and missing is asked once, in a small web form inside LINE, before any stock is held. - "theme" works exactly as in POST /orders and is carried onto every order the chat creates. - "returnUrl" works exactly as in POST /orders: it is your link BACK into your site, rendered on the CowTicket ticket page, and it may contain {ORDER_ID}/{ORDER_TOKEN}. It is carried from the intent onto every order the chat creates. - **You do not host a page in this flow either.** The ticket link the buyer receives in LINE is the CowTicket ticket page — the same one card buyers land on — and it is what they reopen days later from their chat history. Chat checkout never had a storefront page; the rest of the platform simply caught up with it. - Requires the organizer to have PROMPTPAY in the event's paymentMethods; calling it for a card-only event returns an error. One intent per tap is fine — codes are single-buyer (the first LINE account to send one owns it) and creating another costs nothing. - Orders born here come back from GET /orders/detail with "paymentMethod": "PROMPTPAY" (or "FREE"), possibly "email": null, and "buyerName" from LINE. ### After payment — you do not build a ticket page CowTicket hosts it. Every order lands on the "ticketUrl" that POST /orders returned — take it from the response rather than building it, so the URL stays ours to change. Chat-checkout buyers get the same page as a link in LINE. That page is the buyer's tickets, their receipt, and their gate credential: the order QR they hold up, the ticket list with live check-in status, per-ticket codes, and the save-to-camera-roll flow. It is also what the confirmation email and the LINE messages link to, so it is where buyers return days later. Do not rebuild any of it. This is a hard boundary, not a preference. The page is gate-critical — what the order QR is, which code is a live pass and which is a record of entry, what a refunded order shows, what a checked-in badge may and may not claim — and every one of those rules changed the last time check-in changed. A copy living in your storefront is a copy that goes silently wrong at a gate, and nobody finds out until someone is standing at a door being refused. So: - Do not render ticket QRs. - Do not poll for order status. There is no success page for you to hold open. - Do not build a "my tickets" page. Link to the URL above if you keep order history. GET /orders/detail still exists and still works, for order history and support lookups on your side. It is no longer part of the buying flow, and rendering tickets from it is out of contract. ### Styling the ticket page "theme": { "primaryColor": "#16a34a", "secondaryColor": "#111827" } Optional on both POST /orders and POST /orders/intent. Both keys are optional inside it; anything you omit uses the CowTicket default. Values must be hex — "#rgb" or "#rrggbb", nothing else. Named colours, rgb() and colour functions are rejected with a 400, because the value ends up in CSS and the narrow grammar is what keeps that safe. An unrecognised key is also a 400 rather than being ignored, so a typo tells you immediately instead of silently doing nothing. - primaryColor paints the ticket header and the main "save the QR" button. - secondaryColor paints the quieter controls — the per-ticket buttons and the close button on the full-screen QR. - Text on those surfaces is computed from your colour, not chosen by you: white, unless white would fall below WCAG's 3:1 large-text ratio against it, in which case near-black. Deep and mid-tone brand colours therefore take white type, as they do everywhere else; only genuinely light surfaces (yellows, pastels, near-white) flip. You cannot set text colours, and you do not need to. Three things are deliberately not themeable: - **The QR codes.** Always black on white. A tinted QR is a QR that fails at a gate. - **Status colours.** Green "checked in" and red "refunded" carry meaning. If your brand is red, a themed status would make a valid ticket look refunded. - **Layout.** No CSS, no fonts, no logo, no custom HTML. The page is gate-critical and its structure is ours; see the section above. The theme is snapshotted onto the order when it is created, like the unit price. A rebrand changes what new buyers see and leaves tickets already sold exactly as they were. ### Check-in (only if building a scanner/gate feature) POST /tickets/redeem { "accountId": "", "ticketId": "", "location": "" } Requires an organizer-type JWT (exchange an API key of type "organizer" — a second credential, separate from the customer key). ticketId accepts either the ticket uuid or the ticketNo. Tickets are bearer instruments: presenting a valid one is the only requirement, there is no holder identity to verify. A ticket redeems once; REDEEMED, VOIDED and refunded-order tickets are rejected. Most storefronts do not need this — the organizer's own app handles scanning. ## The flow to implement on the storefront 1. Ticket page: GET /events/ticketConfigs → render the ticket options in the given order (sectioned by "group" when present), prices (satang → ฿ divide by 100), availability, and the refund policy text. Respect limitPerOrder. 2. Checkout: render "purchaseForm" and collect exactly those fields, send the answers as metadata (and "principal" also as passDisplay.principal), POST /orders, redirect to checkoutUrl. 3. Redirect to "checkoutUrl" if set, otherwise to "ticketUrl". That is the end of your flow — Stripe returns paying buyers to the ticket page itself, and a free order has nothing to pay so it goes straight there. Pass "returnUrl" so the buyer can walk back into your site from it. There is no step 4. No success page, no polling, no ticket rendering, no cancel page of your own — a cancelled checkout returns to your "returnUrl". If you find yourself writing a QR component, you have left the contract. ## Managing events and ticket options (content sync) The endpoints above are all a pure storefront needs. CowTicket also exposes the event/ticket-option management API over the same REST channel, so a project that already owns event content can push it into CowTicket instead of the user re-typing it in the app. Requirements: an **admin-type** API key whose scopes include "events:manage", exchanged for an **admin** token (POST /accounts/token with "type": "admin"). Anything less fails: a customer token is rejected outright, an organizer token may read but never write, and a key without "events:manage" gets 403 on every write. Send the token as authorization: Bearer , and put the same accountId in every request body — a mismatch with the token's account is 401. These are server-side calls only. Error shape in this section: 400 = malformed request or unknown event/ticket option, 401 = token/account problem, 403 = missing scope, and a rejected *state change* (duplicate code, quantity below sold, archived event) currently comes back as HTTP 500 with the real reason in the plain-text body. Read the body, not just the status. POST /events { "accountId", "name", "eventCode": "spring-fest", // optional — generated if omitted "tags": ["..."], // optional, portal filtering/bulk export "reportEmails": ["..."], // optional, organizer report recipients "refundAllowed": true, // optional, default true "refundTermEn": "...", "refundTermTh": "...", // optional, defaults filled in "paymentMethods": ["STRIPE"], // optional, default ["STRIPE"] "purchaseForm": [ // optional, default: email required { "key": "principal", "type": "text", "required": true, "labelTh": "ชื่อผู้เข้าร่วม", "labelEn": "Attendee name" }, { "key": "email", "type": "email", "required": true, "labelTh": "อีเมล", "labelEn": "Email" } ] } → the created event Omit "eventCode" and one is generated (and retried until unique) — read it back from the response and store it, since every later call identifies the event by it. eventCode is the permanent handle you address the event by everywhere else. It is unique within your account — another account may use the same code, so always pair it with your accountId — and can never be changed. Reusing one in your own account is refused with "Event code already exists". Pick something stable and slug-like from your own content id. PUT /events { "accountId", "eventCode", ...any of: name, tags, reportEmails, refundAllowed, refundTermEn, refundTermTh, paymentMethods, purchaseForm } → the updated event Only the fields you send change. An unknown eventCode is 400 "Event not found". "purchaseForm" replaces the whole list — send every field you want, not a patch. Field keys must start with a letter (letters, numbers, underscores after that) and be unique; "select" needs a non-empty "options"; every field needs both labels. "PROMPTPAY" is reserved and not accepted yet. You never have to add an email field for card payment. An event selling by card always collects a required email — Stripe needs somewhere to send the receipt — so if your form omits one, or marks it optional, the platform adds or promotes it when the form is read. That field comes back with "auto": true. Which means the form you GET back can differ from the one you PUT; always render what GET /events/ticketConfigs gives you rather than your own copy. POST /events/archive { "accountId", "eventCode", "archived": true } // false = unarchive → the updated event ("archivedAt" is set, or null once unarchived) There is no delete: an event carries orders, tickets and payment history, so retiring one means archiving it. An archived event stops selling — GET /events/ticketConfigs returns an empty list and POST /orders is refused with "This event is archived and is not selling tickets" — while orders, tickets, check-in, stats and reports stay exactly as they were. The call is idempotent and reversible; unarchive puts the event straight back on sale under the same eventCode. GET /events?accountId= &archived=true|false // optional, default false = active events only &keyword=&tag=&take=&skip= → { "data": [ { "id", "eventCode", "name", "tags", "reportEmails", "archivedAt", "refundAllowed", "refundTermEn", "refundTermTh", ... } ], "total": n } Ask for archived=true to find an archived event — the default view never lists them. This is how you reconcile: read what exists, then create/update/archive to match. GET /ticketConfigs?accountId=&eventCode= → { "data": [ { "id", "code", "name", "group", "price", "currency", "quantity", "sold", "reserved", "limitPerOrder", "status", "startSellingDate", "endSellingDate", "sessionStartAt", "sessionEndAt", "sessionDate", "sortOrder" } ], "total": n } The merchant view of ticket options — unlike GET /events/ticketConfigs it includes HIDDEN ones, raw counters and the "id" you need to update a row. Match your own ticket types to these by "code" and keep the returned "id". POST /ticketConfigs { "accountId", "eventCode", "code", "name", "price": 50000, // satang; 0 = free ticket "quantity": 100, "limitPerOrder": 4, "startSellingDate": "2026-03-01T00:00:00Z", // optional, ISO 8601, open-ended if null "endSellingDate": "2026-03-14T17:00:00Z", // optional "sessionStartAt": "2026-03-14T10:00:00Z", // optional — when it ADMITS you "sessionEndAt": "2026-03-14T10:30:00Z", // optional; needs sessionStartAt "status": "ACTIVE", // or "HIDDEN"; default ACTIVE "group": "Ringside" } // optional section label (not time) → the created ticket option "code" is unique within the event and is what POST /orders references. Omit it and one is generated for you (and retried until unique) — send your own only if you need to choose it, in which case reusing an existing code is refused. PUT /ticketConfigs { "accountId", "id": "", // the id, NOT the code ...any of: name, price, quantity, limitPerOrder, startSellingDate, endSellingDate, sessionStartAt, sessionEndAt, status, group } → the updated ticket option "code" and "eventCode" are immutable — a ticket option cannot move between events. Lowering "quantity" below sold + reserved is refused: sold tickets can never be un-sold. DELETE /ticketConfigs?accountId=&id= → the deleted ticket option Deletion is allowed **only while nothing references the option** — no orders, no issued tickets. The database enforces that, so it also holds if an order arrives mid-request; the call is refused rather than cascading. Use it to clear away a mistake or a test row. Once anything has been sold, deleting is refused for good: set "status": "HIDDEN" to take the option off sale while keeping its orders, its counters and its place in every report. Nothing else in this API is ever deleted — events, orders and tickets carry money history and are archived or cancelled instead. Sync rules: - CowTicket is the source of truth for price, inventory counters and everything money. Push your content in, then read availability back — never mirror "sold" into your own store and never re-create an event to "reset" it. - Sync is a reconcile, not a replay: list first (GET /events, GET /ticketConfigs), then create what's missing, update what differs, archive what you retired. Re-running it must be safe. - Display order comes from the organizer's arrangement in the CowTicket app; the API has no order field, so new ticket options land at the end in creation order. Create them in the order you want them shown. - Editing a live event is allowed and takes effect immediately — price changes never touch orders already placed (each order snapshots its unit price). - A refusal always carries a plain-text reason (duplicate code, quantity below sold, event not found). Surface it; do not retry blindly. ## Rules and gotchas - You do not wait for payment. The buyer is on the CowTicket ticket page by then, and that page does the waiting — it polls while the order is PENDING and shows the tickets the moment payment confirms. There is no storefront-facing webhook, and none is needed: nothing on your side depends on the outcome. - If you keep order history of your own, read state with GET /orders/detail whenever the buyer looks at it. Never treat a Stripe redirect as proof of payment, and never block a page on reaching PAID — PENDING is normal and legitimate for up to an hour on a PromptPay order, where the buyer is still going off to transfer. - Don't cache availability for more than a few seconds during an on-sale. - Handle 401 by re-exchanging the API key for a fresh JWT once, then retrying. A 401 that survives the retry usually means a missing or mismatched accountId, not an expired token. - A ticket is a bearer instrument owned by the organizer's account. It is not bound to a buyer identity: whoever presents a valid QR gets in, there is no claim/transfer/holder check anywhere in the API, and "principal" is a printed label the gate does not verify. The ticket-page URL is the only thing guarding those tickets, so treat its accessToken as a password — keep the token and order id out of analytics payloads, client-side error reporting, and cross-origin Referer headers. Do not build a "transfer to a friend" feature; there is no API for it, and the ticket page already lets a buyer send an individual ticket to whoever is using it. - Errors are plain-text messages with meaningful status codes (400 validation / sold out / outside selling period, 401 auth). Show the message to the buyer for sold-out and per-order-limit failures. - Handle order-create failures (sold out between render and checkout) by re-fetching availability and telling the buyer what's left. - Do not build your own inventory, order state, or payment handling — CowTicket is the source of truth for all commerce state. The storefront is the source of truth for event content only. - Questions from the human: support@cow-ticket.dev Human-readable docs: https://cow-ticket.dev/docs