Skip to content
Docs
Concept

Offers

What a ticket costs on a sales channel and whether it can be bought: the face value, each fee and where it came from, the total, availability with a reason, a capacity bucket, the order limits and a quote. One engine, the one checkout charges through.

An offer is one thing a buyer can pick on one date: a seating category and a ticket type, on an occurrence, on a sales channel. "Stalls, Adult, on Friday's performance, on the website" is an offer. The Backstage API answers every question a page asks about offers from one route:

GET /v1/events/{eventId}/offers

It returns each offer with its price, fees included, and whether this caller can buy it now, and why not when they cannot. Carts and checkout price through the same engine, so the total an offer shows is what checkout charges for that ticket, and a quote for two tickets is what checkout charges for a cart holding those two. Show a price from here, not from a price scheme: a scheme holds face values, and a buyer pays fees on top.

The operation reference is Get event offers, and @ticketlayer/backstage has it as client.events(eventId).offers.get().

The channel is required#

Prices, fees and sale windows belong to a sales channel. The same ticket can cost more on a partner's site than on your own, a channel's fee profile adds its own booking fee, and a presale can open on one channel before another. So an offer only has an answer on a channel, and the route requires one:

  • send X-Ticketlayer-Channel with the channel's slug, or
  • use a credential that names its own channel, in which case the header, if sent, must agree with it.

Without a channel the answer is 400 CHANNEL_REQUIRED. An event the channel does not show to this caller is 404, the same as reading it on that channel.

The caller needs events.read on the event's account. An organisation API key (tlak_) with the events read scope or a staff token will do.

A worked example#

The event "The Tempest" has one performance, one category (Stalls) and two ticket types. The price scheme says Adult is 20.00 and Concession 15.00, and the website channel has its own Adult price of 22.00. The channel's fee profile adds a 10% service fee per ticket, and the account charges a 1.25 order fee once per order.

curl "https://api.ticketlayer.com/v1/events/evt_8f2kQ/offers?quantity=2" \
  -H "Authorization: Bearer tlak_..." \
  -H "X-Ticketlayer-Org: your-org-slug" \
  -H "X-Ticketlayer-Channel: website"
const offers = await backstage.events('evt_8f2kQ').offers.get({ quantity: '2' });

The Adult offer comes back like this (one offer shown, JSend envelope omitted):

{
  "event": { "id": "evt_8f2kQ", "name": "The Tempest", "timezone": "Europe/London" },
  "channel": { "id": "sch_web", "slug": "website", "name": "Website" },
  "context": { "at": "2026-10-01T09:00:00.000Z", "view": "buyer", "customerId": null, "code": null, "codeApplied": false, "quantity": 2, "cartId": null },
  "summary": { "state": "on_sale", "currency": "GBP", "from": 1775, "to": 2545 },
  "limits": { "minPerOrder": 1, "maxPerOrder": 10, "maxOrderValue": 1000000 },
  "occurrences": [
    {
      "id": "occ_1",
      "startsAt": "2026-10-09T18:30:00.000Z",
      "endsAt": "2026-10-09T21:00:00.000Z",
      "timezone": "Europe/London",
      "status": "on_sale",
      "availability": { "state": "on_sale", "reason": null, "opensAt": null, "closesAt": "2026-10-09T18:00:00.000Z" },
      "offers": [
        {
          "categoryId": "cat_stalls",
          "categoryName": "Stalls",
          "ticketTypeId": "tt_adult",
          "ticketTypeName": "Adult",
          "description": null,
          "price": {
            "currency": "GBP",
            "faceValue": 2200,
            "discounts": [],
            "fees": [
              { "code": "service", "label": "Service fee", "basis": "per_ticket", "rate": 0.1, "amount": 220, "source": { "layer": "channel_profile", "id": "fpr_web" } },
              { "code": "order", "label": "Order fee", "basis": "per_order", "rate": null, "amount": 125, "source": { "layer": "account_config", "id": "acc_main" } }
            ],
            "total": 2545,
            "source": { "layer": "channel", "schemeId": "ps_main", "level": 0, "inForceSchemeId": "ps_main" }
          },
          "unlock": null,
          "availability": { "state": "on_sale", "reason": null, "opensAt": null, "closesAt": "2026-10-09T18:00:00.000Z" },
          "remaining": { "bucket": "limited", "exact": null },
          "limits": { "minPerOrder": 1, "maxPerOrder": 10, "maxPerOrderRemaining": 10 },
          "quote": { "quantity": 2, "faceValue": 4400, "discounts": 0, "fees": 565, "total": 4965 }
        }
      ]
    }
  ]
}

Reading it:

  • faceValue is 2200, the website's own Adult price. price.source.layer is channel because the channel has a price for this cell; the Concession offer, which has none, says default and prices at the scheme's 1500. level is 0 because the price is in the scheme in force; 1 would mean it was inherited from that scheme's parent.
  • fees lists every fee on buying one ticket. The service fee is 10% of 2200 (rate 0.1, amount 220). The order fee is per_order: it is charged once whatever the quantity, and it is in the single-ticket total because one ticket bought alone pays it. Each fee's source names the configuration that charged it: organisation_config, account_config, channel_profile, profile_rule or code_waiver.
  • total is 2545: faceValue - discounts + fees, what one ticket costs. Money is always integer minor units in currency.
  • quote prices quantity (2 here): face value 4400, fees 440 per ticket plus the 125 order fee once, 565, for 4965. A quote is present only when the offer is on sale.
  • summary is the event on this channel in one line: the best state any offer is in, and the cheapest and dearest total among the offers this buyer can pay for now (or, when none is on sale, among those that open later). Concession's total is 1500 + 150 + 125 = 1775.

Availability#

Every occurrence and every offer carries availability: a state, a reason when it is not on sale, and the times that matter to it.

StateMeansCarries
on_saleThis caller can buy it nowclosesAt: when the sale they are under closes, or null when it does not
not_yet_on_saleA sale window opens lateropensAt: when it opens for this caller
sold_outNothing left
off_saleNot on sale, and no window opensclosesAt: when the sale closed, where one did
hiddenThe channel does not show it to this calleronly in the staff view (below)
lockedReserved in the schemasee Offers that need a tag

The state of an occurrence is the same for every offer on it (the event is not live, the performance is cancelled, the sale window has not opened); an offer can then be worse than its occurrence, for example sold_out while other categories on the same date are still on_sale.

A buyer is told one of four coarse reasons, which are safe to show on a page:

ReasonStateTell the buyer
SALE_NOT_OPENnot_yet_on_saleOn sale from opensAt
SOLD_OUTsold_outSold out
SALE_CLOSEDoff_saleSales have closed
NOT_AVAILABLEoff_saleNot available

With view=staff, the reason is the fine one instead, which is what a box office or a support tool needs to fix the event: EVENT_NOT_LIVE, OCCURRENCE_DRAFT, OCCURRENCE_CANCELLED, OCCURRENCE_COMPLETED, NO_PRICE_SCHEME, PRICE_SCHEME_NOT_ACTIVE, NO_PRICE_FOR_TYPE, NO_INVENTORY, OCCURRENCE_SOLD_OUT, NO_CAPACITY, NOT_LISTED and NOT_VISIBLE, alongside SALE_NOT_OPEN and SALE_CLOSED.

Remaining capacity#

remaining.bucket says how much of the offer's category is left, in words a page can show without publishing a count:

BucketMeans
availableMore than 20% of the category's capacity, and more than 10 tickets
limited20% of the capacity or less
few10 tickets or fewer
noneNothing left

bucket is null when the category has no inventory. remaining.exact, the count itself, is filled in only with view=staff for a caller holding inventory.read; everywhere else it is null.

Limits#

limits on the response are the order limits checkout enforces: minPerOrder, maxPerOrder (10 unless the organisation's order configuration sets another) and maxOrderValue, in minor units. Each offer repeats minPerOrder and maxPerOrder in its own limits. Pass cartId, an open cart on the same channel, and each offer's maxPerOrderRemaining is maxPerOrder less what that cart already holds, so a quantity picker can stop at the right number. It is 0 on a sold-out offer.

The buyer view and the staff view#

view=buyer is the default: what a member of the public on the channel is told. It lists the occurrences a buyer on the channel sees (on sale or sold out), the coarse reasons, the bucket without a count, and no unlock.

view=staff lists every occurrence, with the fine reasons and, for a caller holding inventory.read, remaining.exact. It also prices an occurrence whose scheme is not active yet, so the box office can see what a draft says and why it is off sale. A listing that is switched on but not visible right now (a link-only listing outside its window, say) is 404 in the buyer view; in the staff view, for a caller holding events.list_hidden on the channel, it is returned with the state hidden and the reason NOT_VISIBLE.

Pricing for one customer, a code, an instant#

  • customerId prices for that customer: their customer tags are read server-side and admit them to what those tags gate. It needs customers.read on the customer.
  • code previews what a presale code would unlock. It is never redeemed and no use is counted; context.codeApplied says whether it unlocks anything now.
  • at evaluates at another instant (ISO 8601), for example to check what a page will show when a window opens.
  • occurrenceId narrows the response to one date.

Offers that need a tag#

A ticket type can require a customer tag, for example a members' price. An offer of that ticket type is returned only to a caller who holds the tag, because they are the customer named by customerId or because the code previewed grants it; unlock.source then names the tag in the staff view. For anyone else the offer is left out of the response, so a public page never shows a price its reader cannot pay. The schema also declares a locked state with the buyer reason TAG_REQUIRED; the engine does not return it, and a gated offer is always left out rather than returned as locked.

Offers and the event read#

For a card or a list, where one line per event is enough, read the events with include=prices on a channel instead of calling offers once per event. Each event's prices is the same summary the engine computes: from and to (fees included, feesIncluded: true), currency, state and sellable. See Syncing events.