# Checkout Embed (/docs/embed)
The checkout embed puts Jetron's full buying flow inside your page: ticket
selection, quantities, promo codes, contact details, and payment. You supply one
iframe and Jetron handles the rest, so there is no API key, no backend, and no
checkout code on your side.
## Add the embed [#add-the-embed]
Point an iframe at the checkout host followed by your event's slug:
```html
```
That is the entire integration. The slug is the same `slug` the API returns for
the event, so a page that already lists events can build the URL from data it
has.
Your Jetron Ticket dashboard generates this snippet for you, already filled in
with the event slug and your configured theme, under the event's
**Integrations → Website Embed** tab.
## Theming [#theming]
Two query parameters control the look:
| Parameter | Values | Description |
| ------------- | ----------------- | -------------------------------------------------------- |
| `theme` | `light` or `dark` | Colour mode for the checkout. |
| `colorScheme` | 6-digit hex | Accent colour, applied as the checkout's primary colour. |
```html
```
`colorScheme` accepts the hex with or without a leading `#`. If you include the
`#`, URL-encode it as `%23`, since a raw `#` would start a URL fragment:
```js
const accent = '#e0995a'
const src = `https://checkout.jetronticket.com/${slug}?theme=light&colorScheme=${encodeURIComponent(accent)}`
```
If you have set an embed mode or colour on the event in your dashboard, that
configuration is applied after the URL parameters and overrides them. Use the
dashboard when you want one consistent look, and the query parameters when the
embedding page picks the colour per event.
## Sizing [#sizing]
The embed does not resize itself and does not post messages to the parent page,
so give the iframe a height that fits the tallest step of the flow. Around
`760px` works for most events. If your layout allows it, a viewport-relative
height such as `min(760px, 90vh)` handles small screens more gracefully.
Because there is no message channel, the parent page is not notified when a
purchase completes. To react to purchases on your own systems, use
[webhooks](/docs/webhooks).
## When to use the embed [#when-to-use-the-embed]
The embed is the right choice when you want to start selling quickly, when you
do not want to handle payment redirects yourself, or when you are selling a
**Seat Map** event. Seat Map events cannot be sold through the API, but
the embed supports them fully, including the seat picker.
Choose the [API](/docs/concepts/checkout-flow) instead when you need
control over every screen, want to collect custom fields, or are building the
buying experience into an existing product flow.
## Live example [#live-example]
[boba.jtr.rsvp](https://boba.jtr.rsvp/) embeds the checkout on several of its
events and passes each event's accent colour through `colorScheme`, so the
checkout matches the page it sits in. Its source is on GitHub at
[jetronmall/boba-event](https://github.com/jetronmall/boba-event), and
[Example site](/docs/example-site) explains the setup.
# Example site (/docs/example-site)
[**boba.jtr.rsvp**](https://boba.jtr.rsvp/) is a working event site built on
Jetron Fluid, and it is **open source**. It is worth a look because it does
everything at once: the [checkout embed](/docs/embed) on some events, the
[API](/docs/concepts/checkout-flow) on others, and
[webhooks](/docs/webhooks) behind both.
## Who supplies what [#who-supplies-what]
The site looks nothing like Jetron. It has its own domain, fonts, colours, and
layout.
| Jetron supplies | The site supplies |
| --------------------------------------------------------- | ----------------------------------------------- |
| Which events exist, with their dates, posters, and prices | The design: fonts, colours, imagery, and layout |
| How many tickets are left right now | The writing: hero copy and event descriptions |
| Taking payment and sending out the tickets | Everything the visitor actually sees |
Add an event in Jetron Ticket and it appears on the site on its own.
## Two ways to sell, on one site [#two-ways-to-sell-on-one-site]
Each event picks how it sells.
**With the embed**, the page drops in an iframe and passes its own accent colour
so the checkout matches the page around it:
```tsx
```
**With the API**, the site builds its own screens instead: pick quantities,
reserve (which starts a countdown), collect contact details, then send the buyer
off to pay.
```ts
const { data: order } = await client.createOrder(
slug,
{ quoteReference, email, firstName, lastName, callbackUrl, metadata: { bringing } },
{ deviceId: reservation.reservationToken },
)
window.location.assign(order.checkoutUrl)
```
Anything in `metadata` comes back later on the
[webhook](/docs/webhooks/events), which is how the site knows what each buyer
said they were bringing.
## Where the keys go [#where-the-keys-go]
```bash title=".env.local"
JETRON_API_KEY=jt_public_... # reading events on the server
NEXT_PUBLIC_JETRON_API_KEY=jt_public_... # the checkout running in the browser
JETRON_PRIVATE_API_KEY=jt_private_... # checking webhook signatures
```
The public key is safe in the browser, which is why the same value appears
twice. The private key only ever verifies webhooks and never leaves the server.
## Receiving webhooks [#receiving-webhooks]
When someone buys a ticket, Jetron posts to the site's `/api/webhooks/jetron`
route. It checks the signature, then records the purchase against its
`reference` so a repeated delivery updates the same row instead of creating a
second one. The live feed at
[/webhooks](https://boba.jtr.rsvp/webhooks) shows them landing.
See [Verifying signatures](/docs/webhooks/verifying) for the code.
# Introduction (/docs)
**Jetron Ticket** is the ticketing platform: it owns your events, ticket
inventory, payments, and ticket delivery. **Jetron Fluid** is how you bring that
platform into your own product, so attendees buy tickets on your site under your
brand instead of being sent somewhere else.
Fluid gives you two ways to sell, and you can mix them across events.
Both options are backed by the same events and the same inventory, and both can
notify your server through [webhooks](/docs/webhooks) when a purchase completes.
## Which one should I use? [#which-one-should-i-use]
| | Checkout Embed | API |
| ------------------ | -------------------------------------------- | ----------------------------------------------- |
| **What you do** | Paste one line of code into your page | Build the ticket buying screens yourself |
| **Who sets it up** | Anyone who can edit your website | A developer |
| **How it looks** | Your brand colour, in light or dark | Exactly how you design it |
| **Taking payment** | Built in | Built in |
| **Best for** | Selling quickly from a page you already have | A buying experience that is completely your own |
If you are not sure, start with the embed. You can move an event to the API
later without changing anything about the event itself.
## How the API flow works [#how-the-api-flow-works]
### Browse events [#browse-events]
List your public events and their tickets with `GET /events`,
`GET /events/{slug}`, and `GET /events/{slug}/tickets`.
### Reserve tickets [#reserve-tickets]
`POST /events/{slug}/reservations` prices the cart and holds the stock for a
limited window. You get back a `reservationToken` and a `quoteReference`.
### Check out [#check-out]
`POST /events/{slug}/orders` completes checkout. Paid orders return a
`checkoutUrl` to redirect the attendee to. Free orders are confirmed
immediately.
Read the [checkout flow guide](/docs/concepts/checkout-flow) for the full
walkthrough.
## See it running [#see-it-running]
[boba.jtr.rsvp](https://boba.jtr.rsvp/) is a complete white-labelled event site
built on Fluid. It uses the embed on some events and the API on others,
and it receives and verifies webhooks. The whole site is open source at
[jetronmall/boba-event](https://github.com/jetronmall/boba-event), and
[Example site](/docs/example-site) walks through how it is put together.
## Next steps [#next-steps]
## Good to know [#good-to-know]
* All events are automatically scoped to the event host that owns your API key.
* Successful responses are wrapped in an envelope: `{ "data": , "status": true }`.
* Monetary amounts are integers in the smallest currency unit. See [Money](/docs/concepts/money).
* Seat Map events cannot be sold through the API yet and return `400`. Use the embed for those events.
# Quickstart (/docs/quickstart)
## Get an API key [#get-an-api-key]
Jetron Fluid uses **public API keys** in the format `jt_public_...`. Get yours
from your Jetron Ticket host dashboard. Every request is automatically scoped
to the event host that owns the key. See
[Authentication](/docs/concepts/authentication).
## Make your first request [#make-your-first-request]
List your public events:
```bash
npm install @jetronticket/api
```
```ts
import { createClient } from '@jetronticket/api'
const client = createClient({
apiKey: 'jt_public_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
})
const { data: events } = await client.listEvents({ limit: 20 })
console.log(events)
```
```bash
curl https://fluid.jetronticket.com/api/v1/events?limit=20 \
-H "Authorization: Bearer jt_public_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```
Successful responses are wrapped in an envelope. The SDK unwraps it for you;
over raw HTTP it looks like:
```json
{
"data": {
"events": [{ "slug": "my-event", "name": "My Event" }],
"nextCursor": null
},
"status": true
}
```
## Reserve tickets [#reserve-tickets]
Reservations price the cart and hold stock for a limited window:
```ts
const { data: reservation } = await client.createReservation('my-event', {
items: [{ sku: 'ticket-type-uuid', quantity: 2 }],
})
// Keep these: you need them at checkout
reservation.reservationToken
reservation.quoteReference
reservation.clientExpiresAt // drive a countdown in your UI
```
```bash
curl -X POST https://fluid.jetronticket.com/api/v1/events/my-event/reservations \
-H "Authorization: Bearer jt_public_..." \
-H "Content-Type: application/json" \
-d '{ "items": [{ "sku": "ticket-type-uuid", "quantity": 2 }] }'
```
## Create the order [#create-the-order]
Complete checkout by echoing the `reservationToken` as the `X-Device-ID`
header and sending the `quoteReference` back:
```ts
const { data: order } = await client.createOrder(
'my-event',
{
quoteReference: reservation.quoteReference,
email: 'attendee@example.com',
firstName: 'Kendall',
lastName: 'Roy',
callbackUrl: 'https://your-site/checkout/callback',
},
{ deviceId: reservation.reservationToken }, // sent as X-Device-ID
)
if (order.status === 'pending_payment') {
window.location.href = order.checkoutUrl // redirect to the payment gateway
}
```
```bash
curl -X POST https://fluid.jetronticket.com/api/v1/events/my-event/orders \
-H "Authorization: Bearer jt_public_..." \
-H "X-Device-ID: " \
-H "Content-Type: application/json" \
-d '{
"quoteReference": "",
"email": "attendee@example.com",
"firstName": "Kendall",
"lastName": "Roy",
"callbackUrl": "https://your-site/checkout/callback"
}'
```
Paid orders return `status: "pending_payment"` and a `checkoutUrl` to redirect
the attendee to. Free orders are `confirmed` immediately.
## Build with an AI assistant [#build-with-an-ai-assistant]
These docs are also published as plain text, so you can hand them to an AI
assistant instead of copying snippets across by hand.
| Address | What you get | Size |
| ---------------------------------- | -------------------------------------------------- | ----- |
| [`/llms.txt`](/llms.txt) | An index: every page with its description and link | Small |
| [`/llms-full.txt`](/llms-full.txt) | The full text of every page, in one file | Large |
Use **`llms.txt`** with an assistant that can browse. It gives it a map of the
docs so it can go and read only the pages it needs. Use **`llms-full.txt`**
when you would rather paste everything in at once, or save a copy to work
offline.
For a single page, every page in these docs has a **Copy Markdown** button at
the top. The **Open** menu beside it links to that page's raw markdown, which is
handy when you want to point an assistant at one topic rather than all of them.
The docs include working code for both the [embed](/docs/embed) and the API, so
an assistant that has read them can usually scaffold your checkout page and your
[webhook receiver](/docs/webhooks/verifying) without you dictating the details.
## Next steps [#next-steps]
# API Reference (/docs/api-reference)
The Jetron Fluid REST API is served under:
```
https://fluid.jetronticket.com/api/v1
```
Endpoint pages in this section are generated from the OpenAPI spec and include
request/response schemas, code samples, and an interactive playground.
## Authentication [#authentication]
All endpoints except `GET /health` require your public API key as a Bearer
token:
```
Authorization: Bearer jt_public_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Requests are scoped to the event host that owns the key and rate limited per
key. See [Authentication](/docs/concepts/authentication).
## Conventions [#conventions]
* **Envelope**: success responses are `{ "data": , "status": true }`;
errors are `{ "message": string, "status": , "error_code": string }`.
See [Errors](/docs/concepts/errors).
* **Money**: integers in the smallest currency unit (kobo, pesewa, cents).
See [Money](/docs/concepts/money).
* **Caching**: event reads return `ETag` and honor `If-None-Match`.
See [Caching & ETags](/docs/concepts/caching).
* **`X-Device-ID`**: the reservation token header used across the checkout
endpoints. See [Reservations](/docs/concepts/reservations).
## Endpoints [#endpoints]
| Method | Path | Description |
| ------ | ----------------------------- | ------------------------------- |
| GET | `/health` | Health check (unauthenticated) |
| GET | `/events` | List events |
| GET | `/events/{slug}` | Get an event |
| GET | `/events/{slug}/tickets` | List an event's tickets |
| POST | `/events/{slug}/reservations` | Create or refresh a reservation |
| DELETE | `/events/{slug}/reservations` | Release a reservation |
| POST | `/events/{slug}/orders` | Create an order (checkout) |
# Authentication (/docs/concepts/authentication)
Every authenticated endpoint requires your **public API key** as a Bearer
token:
```bash
curl https://fluid.jetronticket.com/api/v1/events \
-H "Authorization: Bearer jt_public_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```
With the SDK, pass it once when creating the client:
```ts
import { createClient } from '@jetronticket/api'
const client = createClient({
apiKey: 'jt_public_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
})
```
## Host scoping [#host-scoping]
All events are automatically scoped to the **event host that owns the API
key**. There is no host or tenant parameter to pass. A key can only see and
sell its own host's public events.
## Key types [#key-types]
Jetron Fluid keys are typed. The API accepts **public** keys
(`jt_public_...`), which are safe to use from a browser: they only expose your
host's already-public events and the attendee checkout flow.
| Response | Meaning |
| ------------------ | --------------------------------------------------------- |
| `401 unauthorized` | The key is missing or invalid. |
| `403` | The key is valid but the wrong type (e.g. a private key). |
Every endpoint except `GET /health` requires a key. Authenticated endpoints are
also rate limited per key.
# Caching & ETags (/docs/concepts/caching)
Event read endpoints (`GET /events` and `GET /events/{slug}`) support
**conditional requests** so you can poll for changes cheaply.
## How it works [#how-it-works]
Each response includes an `ETag` header (an opaque validator for the response
body) plus a `Cache-Control` policy like:
```
Cache-Control: private, max-age=60, stale-while-revalidate=120
```
On your next request, echo the ETag back in the `If-None-Match` header.
If nothing changed, the API responds `304 Not Modified` with **no body**, so
keep using your cached copy. Otherwise you get a fresh `200` with a new `ETag`.
```bash
curl https://fluid.jetronticket.com/api/v1/events/my-event \
-H "Authorization: Bearer jt_public_..." \
-H 'If-None-Match: "the-previous-etag"'
```
## With the SDK [#with-the-sdk]
Every `ApiResult` exposes `etag` and `notModified`:
```ts
const first = await client.getEvent('my-event')
const next = await client.getEvent('my-event', {
ifNoneMatch: first.etag ?? undefined,
})
if (next.notModified) {
// 304: next.data is null, reuse first.data
}
```
See [Conditional requests](/docs/sdk/conditional-requests) for the SDK guide.
Reservations and orders are never cached. Only the event read endpoints
participate in ETag caching. Responses are `private`: cache per user, not in a
shared cache.
# Checkout flow (/docs/concepts/checkout-flow)
Jetron Fluid powers a complete ticket-buying flow on your own frontend. The
flow has three phases: browse, reserve, and order.
### Browse events and tickets [#browse-events-and-tickets]
Use [`GET /events`](/docs/api-reference/events/events/get) to list your host's
public events and
[`GET /events/{slug}/tickets`](/docs/api-reference/events/events/slug/tickets/get)
to show
the purchasable ticket types. Each ticket includes live availability
(`quantityLeft`), pricing, and a `status` such as `AVAILABLE` or `SOLD_OUT`.
### Reserve tickets [#reserve-tickets]
When the attendee picks tickets, call
[`POST /events/{slug}/reservations`](/docs/api-reference/checkout/events/slug/reservations/post).
This
**prices the cart and holds the stock** for the checkout window, and returns:
* `reservationToken`: identifies the attendee's cart. Echo it as the
`X-Device-ID` header on the order and release calls.
* `quoteReference`: an opaque handle to the priced, server-held quote.
* `total`, `totalFees`, `items`: the priced cart, in the smallest currency unit.
* `clientExpiresAt`: drive a **countdown timer** on your checkout page.
Calling it again with the same `X-Device-ID` refreshes/extends the same
reservation instead of holding fresh stock. See
[Reservations](/docs/concepts/reservations) for the details.
### Render your checkout page [#render-your-checkout-page]
Collect the attendee's details (email, first name, last name) on your own
checkout UI while the countdown runs. Tickets are issued to the email you
submit.
### Create the order [#create-the-order]
Call
[`POST /events/{slug}/orders`](/docs/api-reference/checkout/events/slug/orders/post)
with the
`quoteReference` in the body and the `reservationToken` echoed as
`X-Device-ID`:
* **Free (zero-total) orders** are fulfilled immediately and return
`status: "confirmed"` with the order `reference`.
* **Paid orders** return `status: "pending_payment"` and a `checkoutUrl`.
Redirect the attendee there to pay. After payment, the gateway redirects to
your `callbackUrl` with a `reference` query parameter.
A duplicate order for the same reservation returns `409`.
## Releasing a hold early [#releasing-a-hold-early]
If the attendee abandons checkout, you can free the stock immediately with
`DELETE /events/{slug}/reservations` (echo the token as `X-Device-ID`). Holds
otherwise expire on their own.
## Limitations [#limitations]
Seat Map events cannot be sold through the API yet, and the checkout
endpoints return `400` for them. Use the [checkout embed](/docs/embed) to sell
those events, which supports seat selection in full.
Reservations and orders are never cached.
# Errors (/docs/concepts/errors)
## Response envelope [#response-envelope]
Successful responses are wrapped as:
```json
{ "data": { "...": "payload" }, "status": true }
```
Errors return an error envelope instead:
```json
{
"message": "Missing or invalid API key",
"status": 401,
"error_code": "unauthorized"
}
```
| Field | Description |
| ------------ | ---------------------------------------------------------------------------- |
| `message` | Human-readable description, safe to log. |
| `status` | The HTTP status code, repeated in the body. |
| `error_code` | A **stable, machine-readable identifier**. Branch on this, not on `message`. |
## Status codes [#status-codes]
| Status | When |
| ------ | -------------------------------------------------------------------------------------------------- |
| `400` | Malformed body, failed validation, missing reservation token, or a Seat Map event (not supported). |
| `401` | Missing or invalid API key. |
| `403` | Valid key of the wrong type (use a `jt_public_...` key). |
| `404` | The resource doesn't exist **for this host**. Other hosts' events look like 404s. |
| `409` | An order for this reservation is already being processed. |
## Handling errors with the SDK [#handling-errors-with-the-sdk]
The SDK throws a typed [`ApiError`](/docs/sdk/error-handling) for every non-2xx
response, with the parsed envelope on the error object:
```ts
import { ApiError } from '@jetronticket/api'
try {
await client.getEvent('missing-event')
} catch (err) {
if (err instanceof ApiError) {
console.error(err.status, err.errorCode, err.message)
}
}
```
# Money (/docs/concepts/money)
All monetary amounts in Jetron Fluid are **integers in the smallest currency
unit**:
| Country | Currency | Smallest unit | Example |
| ------------ | -------- | ------------- | -------------------- |
| Nigeria | NGN | kobo | `500000` = ₦5,000.00 |
| Ghana | GHS | pesewa | `7500` = GH₵75.00 |
| South Africa | ZAR | cents | `12000` = R120.00 |
This applies to every money field: ticket `price` and `fees`, reservation
`total`, `totalFees`, and `discount`, per-item `price` and `fee`, and order
`total`.
## Formatted values [#formatted-values]
You usually don't need to format prices yourself. The API also returns
human-readable, country-formatted strings alongside the integers:
* `Event.formattedCheapestPrice` (e.g. `"Free"` when 0)
* `Ticket.formattedPrice` (e.g. `"FREE"` when 0) and `Ticket.formattedFees`
```json
{
"price": 500000,
"formattedPrice": "₦5,000",
"fees": 25000,
"formattedFees": "₦250"
}
```
Do arithmetic on the integer values only. If you must display a computed
amount, divide by 100 at the last moment, and never store or send fractional
amounts back to the API.
# Pagination (/docs/concepts/pagination)
`GET /events` returns your host's public events **newest first** with cursor
pagination.
| Query param | Description |
| ----------- | -------------------------------------------------- |
| `limit` | Page size, 1–100 (default 20). |
| `cursor` | Opaque cursor from a previous page's `nextCursor`. |
| `category` | Filter by category slug. |
| `q` | Full-text search query. |
Each page includes a `nextCursor` when more results exist; it is absent on the
last page:
```json
{
"data": {
"items": [{ "slug": "my-event" }],
"nextCursor": "b2Zmc2V0PTIw"
},
"status": true
}
```
## Fetching all pages with the SDK [#fetching-all-pages-with-the-sdk]
```ts
import { createClient, type Event } from '@jetronticket/api'
const client = createClient({ apiKey: 'jt_public_...' })
const events: Event[] = []
let cursor: string | undefined
do {
const { data } = await client.listEvents({ limit: 100, cursor })
if (!data) break
events.push(...data.items)
cursor = data.nextCursor
} while (cursor)
```
Don't parse or construct cursors. Always pass back exactly what the API
returned in `nextCursor`.
# Reservations (/docs/concepts/reservations)
A reservation prices an attendee's cart and **holds the stock** for the
checkout window, so tickets can't be sold out from under them while they pay.
```ts
const { data: reservation } = await client.createReservation('my-event', {
items: [{ sku: 'ticket-type-uuid', quantity: 2 }],
promoCode: 'EARLYBIRD', // optional
})
```
The response contains everything your checkout page needs:
| Field | Purpose |
| ------------------------ | ----------------------------------------------------------------------------------- |
| `reservationToken` | UUID identifying the attendee's cart. Echo as `X-Device-ID` on order/release calls. |
| `quoteReference` | Opaque handle to the priced, server-held quote. Send back when creating the order. |
| `total` / `totalFees` | Priced cart, in the smallest currency unit ([Money](/docs/concepts/money)). |
| `discount` / `promoCode` | Applied promo discount, if any. |
| `items` | Per-ticket price and fee breakdown. |
| `clientExpiresAt` | Deadline to drive your checkout countdown. |
## The `X-Device-ID` header [#the-x-device-id-header]
The `reservationToken` doubles as a device/cart identifier, sent as the
`X-Device-ID` header:
* **Recommended:** generate one stable UUID per attendee (persist it in the
browser, e.g. `localStorage`) and send it on every call. Each attendee then
maps to exactly one reservation, and repeated `POST` calls **refresh and
extend** the same hold instead of holding fresh stock.
* **If omitted** when reserving, the server mints a token and returns it, but
it also falls back to reusing a single reservation per client IP + event, so
distinct attendees behind the same IP (offices, campus networks) would share
a cart. Supply your own per-attendee token in production.
* **Required** when creating an order: echo the `reservationToken` verbatim.
```ts
// Refresh/extend an existing hold for the same attendee
await client.createReservation(
'my-event',
{ items: [{ sku: 'ticket-type-uuid', quantity: 3 }] },
{ deviceId: reservation.reservationToken },
)
```
## Expiry and release [#expiry-and-release]
Holds expire on their own after the checkout window. Use `clientExpiresAt` to
show a countdown and prompt the attendee to re-reserve when it lapses.
To free the stock early (e.g. the attendee empties their cart):
```ts
await client.releaseReservation('my-event', {
deviceId: reservation.reservationToken,
})
// → { released: true }
```
If no token is supplied, the server falls back to releasing the reservation
held for your client IP + event; `400` is returned when there is nothing to
release.
`quantityLeft` on the tickets endpoint already subtracts stock held in other
attendees' reservations, so what you display is what can actually be bought
right now.
# Checkout walkthrough (/docs/sdk/checkout)
This walks through a complete white-label checkout with the SDK. See the
[checkout flow concept](/docs/concepts/checkout-flow) for how the pieces fit
together server-side.
### Show the event and its tickets [#show-the-event-and-its-tickets]
```ts
const { data: event } = await client.getEvent('my-event')
const { data: tickets } = await client.listTickets('my-event')
// Only offer purchasable tickets
const purchasable = tickets.filter((t) => t.status === 'AVAILABLE')
```
Each ticket carries live `quantityLeft`, formatted prices, and per-order limits
(`maximumPurchasableTicketCount`) to enforce in your UI.
### Reserve the cart [#reserve-the-cart]
Use one stable UUID per attendee as the `deviceId` (persist it in the browser)
so repeat calls refresh the same hold:
```ts
const deviceId = getOrCreateDeviceId() // e.g. crypto.randomUUID() in localStorage
const { data: reservation } = await client.createReservation(
'my-event',
{
items: [{ sku: purchasable[0].id, quantity: 2 }],
promoCode: coupon || undefined,
},
{ deviceId },
)
```
### Drive the countdown [#drive-the-countdown]
`clientExpiresAt` is the client-facing deadline for the hold:
```ts
const msLeft = new Date(reservation.clientExpiresAt).getTime() - Date.now()
startCountdown(msLeft, {
onExpired: () => promptToReserveAgain(),
})
```
Display the priced cart from `reservation.items`, `reservation.total`, and
`reservation.totalFees`. Amounts are integers in the smallest currency unit
([Money](/docs/concepts/money)).
### Create the order [#create-the-order]
```ts
const { data: order } = await client.createOrder(
'my-event',
{
quoteReference: reservation.quoteReference,
email: form.email,
firstName: form.firstName,
lastName: form.lastName,
phone: form.phone,
callbackUrl: 'https://your-site/checkout/callback',
metadata: { seatPreference: 'front' }, // optional, echoed in the TICKET_PURCHASED webhook
},
{ deviceId: reservation.reservationToken },
)
```
### Handle the result [#handle-the-result]
```ts
if (order.status === 'confirmed') {
// Free order: fulfilled immediately, order.reference is set
showConfirmation(order.reference)
} else {
// Paid order: send the attendee to the payment gateway
window.location.href = order.checkoutUrl
}
```
After payment, the gateway redirects to your `callbackUrl` with a `reference`
query parameter. Verify it on your callback page and show the confirmation.
## Abandoned carts [#abandoned-carts]
Release the hold if the attendee bails out, so the stock frees up immediately:
```ts
await client.releaseReservation('my-event', { deviceId })
```
Creating a second order for the same reservation returns a `409`. Catch the
[`ApiError`](/docs/sdk/error-handling) and treat it as "payment already in
progress".
# Client & methods (/docs/sdk/client)
## Creating a client [#creating-a-client]
```ts
import { createClient } from '@jetronticket/api'
const client = createClient({
apiKey: 'jt_public_...',
})
```
## Methods [#methods]
One method per endpoint:
| Method | Endpoint |
| ----------------------------------------- | ------------------------------------ |
| `health()` | `GET /health` (unauthenticated) |
| `listEvents(params?)` | `GET /events` |
| `getEvent(slug, options?)` | `GET /events/{slug}` |
| `listTickets(slug, options?)` | `GET /events/{slug}/tickets` |
| `createReservation(slug, body, options?)` | `POST /events/{slug}/reservations` |
| `releaseReservation(slug, options?)` | `DELETE /events/{slug}/reservations` |
| `createOrder(slug, body, options)` | `POST /events/{slug}/orders` |
Every method accepts per-request options with `signal` (an `AbortSignal`) and
`headers`. The checkout methods additionally accept `deviceId`, which is sent
as the `X-Device-ID` header: optional when reserving or releasing, **required**
when creating an order.
## The `ApiResult` envelope [#the-apiresult-envelope]
Every method resolves to an `ApiResult` with the payload already unwrapped
from the response envelope:
```ts
interface ApiResult {
data: T // unwrapped payload (null on a 304 Not Modified)
status: number // HTTP status code
etag: string | null // response ETag, when present
notModified: boolean // true when a conditional GET returned 304
response: Response // the raw fetch Response
}
```
## Escape hatch: `request()` [#escape-hatch-request]
For anything not covered by a dedicated method, `request()` performs an
authenticated call against the same base URL and unwraps the envelope:
```ts
const { data } = await client.request<{ example: string }>('GET', '/some/path', {
query: { key: 'value' },
})
```
## Exported types [#exported-types]
All request/response types are exported and mirror the
[API Reference](/docs/api-reference) schemas, including `Event`, `EventList`,
`Ticket`, `Reservation`, `Order`, `CreateReservationRequest`,
`CreateOrderRequest`, and the enums `EventStatus`, `TicketStatus`,
`OrderStatus`, and `Country`:
```ts
import type { Event, Ticket, Reservation, Order } from '@jetronticket/api'
```
# Conditional requests (/docs/sdk/conditional-requests)
`listEvents` and `getEvent` support [ETag caching](/docs/concepts/caching).
Pass a previous `etag` back as `ifNoneMatch`; a `304` resolves with
`notModified: true` and `data: null`, so you keep using your cached value:
```ts
const first = await client.getEvent('my-event')
const next = await client.getEvent('my-event', {
ifNoneMatch: first.etag ?? undefined,
})
if (next.notModified) {
// nothing changed: reuse first.data
}
```
## A tiny cache helper [#a-tiny-cache-helper]
```ts
import type { Event } from '@jetronticket/api'
const cache = new Map()
async function getEventCached(slug: string): Promise {
const hit = cache.get(slug)
const result = await client.getEvent(slug, {
ifNoneMatch: hit?.etag,
})
if (result.notModified) return hit!.data
if (result.data && result.etag) {
cache.set(slug, { etag: result.etag, data: result.data })
}
return result.data
}
```
This pairs well with polling. Refreshing an event page every minute costs a
`304` with no body unless something actually changed.
Reservations and orders are never cached; `ifNoneMatch` exists only on
`listEvents` and `getEvent`.
# Error handling (/docs/sdk/error-handling)
Non-2xx responses throw an **`ApiError`** with the parsed
[error envelope](/docs/concepts/errors):
```ts
import { ApiError } from '@jetronticket/api'
try {
await client.getEvent('missing-event')
} catch (err) {
if (err instanceof ApiError) {
err.status // 404: the HTTP status code
err.errorCode // stable machine-readable identifier
err.message // human-readable description
err.body // the raw parsed error body
err.response // the raw fetch Response
}
}
```
Branch on `errorCode` (and `status`), never on `message`, which can change.
## A typical checkout guard [#a-typical-checkout-guard]
```ts
import { ApiError } from '@jetronticket/api'
async function placeOrder() {
try {
return await client.createOrder('my-event', body, { deviceId })
} catch (err) {
if (!(err instanceof ApiError)) throw err // network failure, abort, etc.
switch (err.status) {
case 400:
// Validation failed or the hold expired: re-reserve and retry
return promptToReserveAgain()
case 409:
// An order for this reservation is already being processed
return showPaymentInProgress()
default:
return showGenericError(err.message)
}
}
}
```
## Non-HTTP failures [#non-http-failures]
Only HTTP error responses become `ApiError`. Network failures and aborted
requests reject with the underlying `fetch` error (e.g. `TypeError`,
`DOMException` for aborts), so keep the `instanceof` check in place:
```ts
const controller = new AbortController()
try {
await client.listEvents({ signal: controller.signal })
} catch (err) {
if (err instanceof ApiError) handleApiError(err)
else if (err instanceof DOMException && err.name === 'AbortError') {
// request was cancelled
} else throw err
}
```
# Overview (/docs/sdk)
**`@jetronticket/api`** is the official TypeScript client for Jetron Fluid. It
wraps the REST endpoints as typed functions using the platform `fetch`, so it
runs in **browsers and Node.js 18+ with no dependencies**. It ships ESM and
CJS builds with full type declarations, and mirrors the
[OpenAPI spec](/docs/api-reference) exactly.
## Install [#install]
npm
pnpm
yarn
bun
```bash
npm install @jetronticket/api
```
```bash
pnpm add @jetronticket/api
```
```bash
yarn add @jetronticket/api
```
```bash
bun add @jetronticket/api
```
## Quick start [#quick-start]
```ts
import { createClient } from '@jetronticket/api'
const client = createClient({
apiKey: 'jt_public_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
})
// Browse events (data is unwrapped from the response envelope)
const { data: events } = await client.listEvents({ limit: 20, q: 'jazz' })
// Checkout flow
const { data: reservation } = await client.createReservation('my-event', {
items: [{ sku: 'ticket-type-uuid', quantity: 2 }],
})
const { data: order } = await client.createOrder(
'my-event',
{
quoteReference: reservation.quoteReference,
email: 'attendee@example.com',
firstName: 'Kendall',
lastName: 'Roy',
callbackUrl: 'https://your-site/checkout/callback',
},
{ deviceId: reservation.reservationToken }, // echoed as X-Device-ID
)
if (order.status === 'pending_payment') {
window.location.href = order.checkoutUrl // redirect to the payment gateway
}
```
## Guides [#guides]
# Event types (/docs/webhooks/events)
Every delivery shares the same envelope. Discriminate on `eventType`:
```ts
interface JetronWebhookEvent {
eventType: 'TICKET_PURCHASED' | 'WEBHOOK_TEST'
data: Record
}
```
## `TICKET_PURCHASED` [#ticket_purchased]
Sent when an attendee completes a purchase.
```json
{
"eventType": "TICKET_PURCHASED",
"data": {
"eventId": "3f8a1c62-9d47-4b1e-9c2a-7e5f0b6d8a11",
"reference": "jt_txn_01H9Z4K8",
"amount": 500000,
"formattedAmount": "NGN 5,000.00",
"paidAt": "2026-08-05T12:00:00Z",
"sentAt": "2026-08-05T12:00:01Z",
"orders": [
{
"sku": "8c1d5e90-2f3b-4a7c-8e6d-1b9f4c2a7d35",
"amount": 500000,
"formattedAmount": "NGN 5,000.00",
"quantity": 1,
"customer": {
"email": "kendall@example.com",
"firstName": "Kendall",
"lastName": "Roy",
"phone": "+2348012345678"
}
}
],
"metadata": { "seatPreference": "front" }
}
}
```
### `data` [#data]
### `orders[]` [#orders]
There is one entry per recipient, so a cart that assigns tickets to several
email addresses arrives as several `orders` entries with `quantity: 1` rather
than one entry with a higher quantity.
A free order is never charged, so `paidAt` and `sentAt` come back as the zero
timestamp `"0001-01-01T00:00:00Z"`. Check `amount` before treating those as real
dates.
## `WEBHOOK_TEST` [#webhook_test]
Sent only when you press **Test** in the dashboard. It confirms that your
endpoint is reachable and that your signature verification works, before real
money is involved.
```json
{
"eventType": "WEBHOOK_TEST",
"data": {
"message": "This is a test webhook from Jetron.",
"reference": "test_ref_1754395200",
"sentAt": "2026-08-05T12:00:00Z"
}
}
```
`WEBHOOK_TEST` carries only `message`, `reference`, and `sentAt`. It does **not**
include `orders`, `amount`, or `metadata`. If your handler is typed against
`TICKET_PURCHASED` and reads `data.orders` without checking `eventType` first,
a test delivery will crash it.
Test deliveries are never retried, so a failing test shows up as a single
attempt.
## Metadata [#metadata]
Anything you pass as `metadata` when [creating an order](/docs/sdk/checkout) is
returned here untouched, which is the simplest way to correlate a purchase with
something in your own system:
```ts
await client.createOrder(slug, {
// ...
metadata: { cartId: 'abc123', source: 'newsletter' },
}, { deviceId })
```
```json
"metadata": { "cartId": "abc123", "source": "newsletter" }
```
It must be a JSON object rather than a scalar or array, and is capped at 4096
bytes.
# Overview (/docs/webhooks)
Webhooks tell your server that something happened in Jetron Ticket. When an
attendee completes a purchase, Jetron sends a signed `POST` to a URL you
register, so you can fulfil, record, or notify without polling the API.
Webhooks work the same way for both integration paths. Whether the sale came
through the [checkout embed](/docs/embed) or the
[API](/docs/concepts/checkout-flow), you receive the same event. For
the embed, where the parent page never learns that a purchase happened, webhooks
are the only way to react on your side.
## Registering an endpoint [#registering-an-endpoint]
Add your endpoint in the Jetron Ticket dashboard, on the event's **Integrations**
tab. There is no signing secret to copy: Jetron signs deliveries with your
**private API key**, which you already have.
A few constraints apply:
* The URL must use **HTTPS**.
* Each event may have up to **3** enabled subscriptions.
* A URL can only be registered **once across the platform**, so use a distinct
path per event if you want one service to receive several, for example
`/api/webhooks/jetron/my-event`.
* Private, loopback, and link-local addresses are rejected, so you cannot point
a subscription at `localhost`. To develop locally, use a tunnel such as
ngrok or Cloudflare Tunnel.
Deliveries are signed with your private API key. If you rotate that key, every
webhook receiver has to be updated with the new value or signatures will stop
verifying.
## What a delivery looks like [#what-a-delivery-looks-like]
```http
POST /api/webhooks/jetron HTTP/1.1
Content-Type: application/json
User-Agent: Jetron-Webhook-Dispatcher/1.0
X-Jetron-Signature: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08...
{ "eventType": "TICKET_PURCHASED", "data": { "...": "..." } }
```
Every body shares one envelope: a discriminating `eventType` and a `data`
object. See [Event types](/docs/webhooks/events) for the payloads, and
[Verifying signatures](/docs/webhooks/verifying) before you trust any of it.
## Delivery behaviour [#delivery-behaviour]
Your endpoint should acknowledge quickly with a `2xx` and do real work
afterwards.
| Behaviour | Detail |
| -------------- | ------------------------------------------------------------------------------------------ |
| Retries | Up to 5 attempts, with backoff of 1s, 2s, 4s, then 8s |
| Overall budget | The whole delivery is capped at about 30 seconds, so in practice expect roughly 3 attempts |
| Timeout | 10 seconds per attempt |
| Success | Any status from `200` to `399` |
| Redirects | Not followed. A `3xx` counts as delivered, so never redirect your webhook URL |
| Ordering | Not guaranteed. Concurrent purchases can arrive out of order |
| Duplicates | Possible. Retries reuse the same body and signature |
There is no server-side deduplication and no replay protection. Treat
`data.reference` as the idempotency key: if you have already processed that
reference, acknowledge the delivery and do nothing else.
Only genuine attendee purchases trigger `TICKET_PURCHASED`. Complimentary and
admin-generated orders do not.
## A minimal receiver [#a-minimal-receiver]
```ts title="app/api/webhooks/jetron/route.ts"
export const runtime = 'nodejs' // HMAC needs the Node runtime
export const dynamic = 'force-dynamic'
export async function POST(req: Request) {
const raw = await req.text() // read the raw body before parsing
const signature = req.headers.get('x-jetron-signature')
if (!verifySignature(raw, signature)) {
return Response.json({ error: 'invalid signature' }, { status: 401 })
}
const event = JSON.parse(raw)
if (event.eventType === 'TICKET_PURCHASED') {
await fulfil(event.data) // idempotent on event.data.reference
}
return Response.json({ ok: true })
}
```
`verifySignature` is on the next page. The one thing to get right here is
reading the **raw** body first: parsing and re-serializing changes the bytes and
the signature will never match.
## Live example [#live-example]
[boba.jtr.rsvp/webhooks](https://boba.jtr.rsvp/webhooks) shows a working
receiver with a live feed of verified deliveries, including retry counts. The
receiver is open source at
[jetronmall/boba-event](https://github.com/jetronmall/boba-event), and
[Example site](/docs/example-site) explains it.
# Verifying signatures (/docs/webhooks/verifying)
Your webhook URL is a public endpoint, so anyone can post to it. Every genuine
delivery carries an `X-Jetron-Signature` header, and you should reject anything
whose signature does not verify.
## How deliveries are signed [#how-deliveries-are-signed]
Jetron computes an HMAC over the exact bytes of the request body:
```
X-Jetron-Signature = hex( HMAC_SHA512( yourPrivateApiKey, rawRequestBody ) )
```
| | |
| -------------- | -------------------------------------------------------------------------------- |
| Algorithm | **HMAC-SHA512** |
| Key | Your **private API key** (`jt_private_...`). There is no separate webhook secret |
| Signed content | The raw request body only. No timestamp or prefix is mixed in |
| Encoding | Lowercase hex, 128 characters, with no `sha512=` or `v1=` prefix |
| Header | `X-Jetron-Signature` |
Verification needs your private key, so it can only happen on your server. Never
ship it to the browser. This is a different key from the `jt_public_...` key
used to call the API.
## Verifying in Node.js [#verifying-in-nodejs]
```ts title="lib/verify-webhook.ts"
import { createHmac, timingSafeEqual } from 'node:crypto'
export const SIGNATURE_HEADER = 'x-jetron-signature'
export function verifySignature(rawBody: string, signature: string | null): boolean {
const key = process.env.JETRON_PRIVATE_API_KEY
if (!key) throw new Error('JETRON_PRIVATE_API_KEY is not set')
if (!signature) return false
const expected = createHmac('sha512', key).update(rawBody, 'utf8').digest('hex')
// Compare equal-length buffers so timingSafeEqual cannot throw on a length
// mismatch, which would leak the result through the exception.
const received = Buffer.from(signature.trim(), 'utf8')
const digest = Buffer.from(expected, 'utf8')
if (received.length !== digest.length) return false
return timingSafeEqual(received, digest)
}
```
Two details matter here:
* **Compare in constant time.** A plain `===` on the hex string leaks timing
information that can be used to forge a signature one character at a time.
* **Guard the length first.** Node's `timingSafeEqual` throws when the buffers
differ in length, and an unhandled throw is itself an observable difference.
## Wiring it into a route [#wiring-it-into-a-route]
```ts title="app/api/webhooks/jetron/route.ts"
import { SIGNATURE_HEADER, verifySignature } from '@/lib/verify-webhook'
export const runtime = 'nodejs' // HMAC is unavailable on edge runtimes
export const dynamic = 'force-dynamic'
export async function POST(req: Request) {
const raw = await req.text()
const signature = req.headers.get(SIGNATURE_HEADER)
let verified: boolean
try {
verified = verifySignature(raw, signature)
} catch (err) {
// Missing key is a server misconfiguration, not a bad request
return Response.json({ error: (err as Error).message }, { status: 500 })
}
if (!verified) {
return Response.json({ error: 'invalid or missing signature' }, { status: 401 })
}
const event = JSON.parse(raw)
if (event.eventType === 'TICKET_PURCHASED') {
await fulfil(event.data)
}
return Response.json({ ok: true })
}
```
`await req.json()` followed by `JSON.stringify` does **not** reproduce the bytes
that were signed. Key order and whitespace change, and the HMAC will never
match. Read the body as text first, verify, then parse that same text.
## Verifying in other languages [#verifying-in-other-languages]
Python
Go
PHP
Ruby
```python
import hashlib
import hmac
import os
def verify_signature(raw_body: bytes, signature: str | None) -> bool:
key = os.environ["JETRON_PRIVATE_API_KEY"].encode()
if not signature:
return False
expected = hmac.new(key, raw_body, hashlib.sha512).hexdigest()
return hmac.compare_digest(expected, signature.strip())
```
```go
func VerifySignature(rawBody []byte, signature, privateKey string) bool {
mac := hmac.New(sha512.New, []byte(privateKey))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(strings.TrimSpace(signature)))
}
```
```php
```ruby
require "openssl"
def verify_signature(raw_body, signature)
return false if signature.nil?
key = ENV.fetch("JETRON_PRIVATE_API_KEY")
expected = OpenSSL::HMAC.hexdigest("SHA512", key, raw_body)
OpenSSL.secure_compare(expected, signature.strip)
end
```
## Replay protection [#replay-protection]
Deliveries carry no timestamp and no nonce, so a captured request stays valid
indefinitely. A verified signature proves the body came from Jetron, not that it
is fresh or that you have not already seen it.
Deduplicate on `data.reference`, which is stable across retries of the same
purchase:
```ts
if (await alreadyProcessed(event.data.reference)) {
return Response.json({ ok: true }) // acknowledge, do nothing
}
await fulfil(event.data)
await markProcessed(event.data.reference)
```
## Testing your endpoint [#testing-your-endpoint]
Press **Test** on the subscription in your dashboard to send a
[`WEBHOOK_TEST`](/docs/webhooks/events) delivery. It is signed exactly like a
real event, so it exercises your verification path end to end. Test deliveries
are never retried, so a `401` shows up as a single failed attempt rather than
several.
# Health check (/docs/api-reference/system/health/get)
Readiness probe. Postgres and Redis are hard dependencies. The service
reports `503` if either is unreachable. Meilisearch, RabbitMQ, Neo4j and
MongoDB are reported for observability but do not affect the status code.
# List events (/docs/api-reference/events/events/get)
Lists the API key host's public events, newest first, with cursor pagination.
# Get an event (/docs/api-reference/events/events/slug/get)
Fetches a single public event by its slug, scoped to the API key host.
# List an event's tickets (/docs/api-reference/events/events/slug/tickets/get)
Lists the purchasable ticket types for an event, ordered by their display position.
# Create or refresh a reservation (/docs/api-reference/checkout/events/slug/reservations/post)
Prices the cart and holds the requested stock for the checkout window.
Returns a `reservationToken` and a `quoteReference`. Send the
`X-Device-ID` header to refresh/extend an existing reservation for the
same attendee; omit it to start a new one (a token is minted and
returned). Seat Map events return `400`.
# Release a reservation (/docs/api-reference/checkout/events/slug/reservations/delete)
Releases a held reservation, freeing the stock. Echo the
`reservationToken` as the `X-Device-ID` header. If omitted, the server
falls back to the reservation held for your client IP + event (the same
fallback used when reserving without a token). Returns `400` if neither
a token nor a matching anonymous reservation is found.
# Create an order (checkout) (/docs/api-reference/checkout/events/slug/orders/post)
Completes checkout for a held reservation. Echo the `reservationToken`
as the `X-Device-ID` header and send the `quoteReference` from the
reservation response. Free (zero-total) orders are
fulfilled immediately and return `status: confirmed`; paid orders return
`status: pending_payment` and a `checkoutUrl` to redirect the attendee
to. After payment the gateway redirects to your `callbackUrl` with a
`reference` query parameter. A duplicate order for the same reservation
returns `409`.