Syncing events
Keep a copy of your events on your own site or CMS: read them with include=occurrences,venue,prices on your channel, page with a cursor, stay current with the event and listing webhooks, and backfill with updatedSince.
This guide is for a server that keeps its own copy of your events, such as a CMS, a static site build or a WordPress plugin. The copy stays current by reading the Backstage API's two event routes:
GET /v1/events, the listGET /v1/events/{id}, one event
Every event includes the fields a public page shows: name, subtitle,
shortDescription, description, images (card and hero), venueName,
ticketTypes and presentation. It also includes contentUpdatedAt, which
is the field a sync depends on. include adds the dates, the venue and the
prices.
You need an organisation API key (tlak_) with the events read scope, or a
staff token, plus the X-Ticketlayer-Org header. See
Keys and authentication.
Read what a channel lists#
Send X-Ticketlayer-Channel with the slug of the channel your site sells
through. The list then returns what that channel lists right now, and each
event carries a listing block that says how the channel lists it. Without
the header you get the administrative view: every event, drafts included.
curl "https://api.ticketlayer.com/v1/events?include=occurrences,venue,prices&limit=50" \
-H "Authorization: Bearer tlak_..." \
-H "X-Ticketlayer-Org: your-org-slug" \
-H "X-Ticketlayer-Channel: website"include is comma-separated:
| Value | Adds |
|---|---|
occurrences | occurrences: each date's startsAt, endsAt, timezone, local startDate and startTime, status, and the occurrence's own venue when it has one. On a channel, only the dates a buyer there sees (on sale or sold out). |
venue | venue: id, name, streetAddress, city, country, coordinates, timezone and website, or null when the event has no venue. |
prices | prices: what a buyer on the channel pays for one ticket, fees included. See Show prices. The list needs a channel for it, otherwise 400 CHANNEL_REQUIRED. |
The list returns at most 50 occurrences per event. When an event has more,
occurrencesTruncated is true. For that event, read
GET /v1/events/{id}?include=occurrences, which returns every occurrence.
include=venue replaces expand=venue, which is deprecated. images.card.url
and images.hero.url replace imageUrl and heroImageUrl, which are also
deprecated.
By default a channel-scoped list returns only what the channel lists
(visibility=discoverable). visibility=visible also returns link-only
listings: events a buyer can open by link that are not in the list. Each of
those has listing.discoverable: false, so you can publish it as an unlisted
page.
Page with a cursor#
Pass pagination.nextCursor from each page back as cursor until it is
null. Keyset pages are stable while events are created or edited between
requests. With page, the same event can appear on two pages or be skipped.
import { BackstageClient } from '@ticketlayer/backstage';
const backstage = new BackstageClient({
baseUrl: 'https://api.ticketlayer.com',
accessToken: process.env.TICKETLAYER_API_KEY, // tlak_...
organisationSlug: 'your-org-slug',
headers: { 'X-Ticketlayer-Channel': 'website' },
});
async function readAll(updatedSince?: string) {
const events = [];
let cursor: string | undefined;
do {
const page = await backstage.events.list({
include: ['occurrences', 'venue', 'prices'],
limit: '50',
...(updatedSince ? { updatedSince } : {}),
...(cursor ? { cursor } : {}),
});
events.push(...page.events);
cursor = page.pagination.nextCursor ?? undefined;
} while (cursor);
return events;
}include, updatedSince and cursor are typed in @ticketlayer/backstage
2.1.0 and later.
Show prices#
With include=prices on a channel, each event carries prices, computed by
the offers engine, the one checkout charges
through:
"prices": {
"currency": "GBP",
"from": 1775,
"to": 2545,
"feesIncluded": true,
"state": "on_sale",
"sellable": true
}fromandtoare what a buyer pays for one ticket of the cheapest and the dearest offer on sale, fees included, in integer minor units. When nothing is on sale yet they cover the offers that open later, and they are null when there are none.stateis the best state any offer is in for a buyer:on_sale,not_yet_on_sale,sold_out,off_sale, orhiddenfor a listing a buyer cannot see.sellableistruewhen it ison_sale.
Show from as the price on a card ("From 17.75"). For the full breakdown on
an event page (each ticket type, its fees, what is left, the order limits),
read the event's offers.
Prices depend on the channel: a channel can have its own price for a ticket,
and its own fees. That is why the list needs one, and why a sync reads prices
on the channel its site sells through. GET /v1/events/{id}?include=prices
without a channel returns prices.byChannel instead: the same summary for
each channel that lists the event.
listing.priceFrom on a channel-scoped read is the lowest face value, before
fees. A page that shows a price shows prices.from.
Keep up to date with webhooks#
Subscribe an endpoint to the event and listing webhooks, and the sync hears about a change as it happens instead of on its next poll:
| Webhook | The sync |
|---|---|
event.updated | Re-fetches the event on its channel. changes names what changed (content, images, status, occurrences, prices, venue, layout). |
event.deleted | Deletes the event. |
listing.changed, listing.status_changed | Re-fetches the event on its channel: the listing was edited, or a sale window opened or closed. |
venue.updated needs no handler of its own here: the events at the venue that
are on sale also receive event.updated with venue in changes. The
payloads are in Webhooks,
and registering an endpoint and verifying the signature are in
Receive webhooks.
The payloads are thin: they say which event changed, not what it now says. A receiver reads the event from the API, so it always stores the current version whatever order deliveries arrive in. Two rules keep it correct under retries and reordering:
- Deduplicate on
Ticketlayer-Event-Id. Deliveries are at least once; a retry carries the same id. - Order on
sequence.event.updatedcarries asequencethat is strictly increasing per event. Keep the highest one processed for each event and drop a delivery below it.
import express from 'express';
import { BackstageAPIError } from '@ticketlayer/backstage';
// backstage: the client from "Page with a cursor", with X-Ticketlayer-Channel set.
// verifyTicketlayerSignature: from "Receive webhooks".
app.post('/webhooks/ticketlayer', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyTicketlayerSignature(raw, req.get('Ticketlayer-Signature') ?? '', process.env.TL_WEBHOOK_SECRET!)) {
return res.status(400).send('bad signature');
}
const deliveryEventId = req.get('Ticketlayer-Event-Id')!;
if (await alreadyProcessed(deliveryEventId)) return res.sendStatus(204);
const { type, data } = JSON.parse(raw);
switch (type) {
case 'event.updated': {
const held = await storedSequence(data.id); // null when you hold none
if (held !== null && data.sequence < held) break; // a newer delivery was processed
await refresh(data.id, data.sequence);
break;
}
case 'event.deleted':
await remove(data.id);
break;
case 'listing.changed':
case 'listing.status_changed':
if (data.channel.slug === 'website') await refresh(data.eventId, null);
break;
}
await markProcessed(deliveryEventId);
res.sendStatus(204);
});
/** Re-read the event on the channel: store it, or delete it when the channel no longer shows it. */
async function refresh(eventId: string, sequence: number | null) {
try {
const { event } = await backstage.events.get(eventId, { include: ['occurrences', 'venue', 'prices'] });
await upsert(event, sequence); // keyed by event.id; keep the higher sequence
} catch (error) {
if (error instanceof BackstageAPIError && error.statusCode === 404) {
await remove(eventId);
return;
}
throw error; // answer 5xx: the delivery is retried
}
}Respond within 15 seconds. When a re-fetch can take longer, record the
delivery, answer 204 and do the work from a queue.
event.updated is sent at most once every 30 seconds per event, with every
area that changed in that time, so a burst of edits is one re-fetch.
Backfill with updatedSince#
Webhooks carry changes from the moment an endpoint subscribes. For the
first load, and to catch up after the receiver has been down for longer than
retries last, read with updatedSince.
updatedSince returns the events whose contentUpdatedAt is at or after the
instant you pass. contentUpdatedAt changes when anything a public page of
the event shows changes: the event, one of its occurrences, a price scheme,
its layout or its venue. An edited date or a renamed venue brings the event
back, even though the event row itself did not change.
Keep a watermark: the highest contentUpdatedAt you have stored. Ask for
everything since the watermark minus a few seconds. A change is stamped
when it is made but only becomes visible when its transaction commits. A
write in flight while you read can therefore carry a stamp just below the
highest one you saw, and without the overlap you would miss it.
const OVERLAP_MS = 5_000;
async function catchUp(watermark: string | null) {
const since = watermark
? new Date(new Date(watermark).getTime() - OVERLAP_MS).toISOString()
: undefined;
const changed = await readAll(since);
for (const event of changed) {
await upsert(event, null); // keyed by event.id
}
// The list is ordered newest-created first, not by contentUpdatedAt,
// so take the maximum over everything read.
return changed.reduce<string | null>(
(max, e) => (max === null || e.contentUpdatedAt > max ? e.contentUpdatedAt : max),
watermark,
);
}The overlap means an event can come back that you have already stored. Make
upsert idempotent by id, and skip the write when the stored
contentUpdatedAt is the same.
Reconcile#
updatedSince finds events whose content changed. It does not find events
that stopped being on the channel, or events that were deleted; the webhooks
above do. As a safety net for a delivery that was never processed, run a full
read without updatedSince now and then, for example nightly:
- delete the events you hold that are not in it
- store the ones you do not hold
The full parameter reference is under List events and Get event.