Skip to content
Docs
Task

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 list
  • GET /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:

ValueAdds
occurrencesoccurrences: 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).
venuevenue: id, name, streetAddress, city, country, coordinates, timezone and website, or null when the event has no venue.
pricesprices: 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
}
  • from and to are 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.
  • state is the best state any offer is in for a buyer: on_sale, not_yet_on_sale, sold_out, off_sale, or hidden for a listing a buyer cannot see. sellable is true when it is on_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:

WebhookThe sync
event.updatedRe-fetches the event on its channel. changes names what changed (content, images, status, occurrences, prices, venue, layout).
event.deletedDeletes the event.
listing.changed, listing.status_changedRe-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.updated carries a sequence that 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.