@ticketlayer/live
v5.9.0
Published
Ticketlayer Live SDK - buyer-facing client (browser + React Native)
Readme
@ticketlayer/live
The buyer-facing Ticketlayer SDK. It runs in the browser (@ticketlayer/live),
in React Native (@ticketlayer/live/native) and, from a separate entry, on
the server (@ticketlayer/live/server).
npm install @ticketlayer/liveThird-party pages load the same client from the CDN build instead. The
vault page architecture/sdk-architecture.md lists what is on npm.
Browser
import { createLiveClient } from '@ticketlayer/live';
const tl = await createLiveClient({
baseUrl: 'https://live-staging.t9r.dev',
publishableKey: 'tlpk_...', // the channel's publishable key (embed); omit on a hosted storefront
checkout: { mode: 'modal', embedBaseUrl: 'https://hbo.staging.t9r.dev' },
});The checkout modal hands the page's theme to the hosted checkout, so the frame
wears the brand the Elements around it wear: every token a brand may set (the
ones with a theme key in @ticketlayer/theme) that this page resolves to
something other than its default, read from document.documentElement and
document.body, travels in the iframe URL's theme parameter. A page at the
defaults sends nothing. checkout: { mode: 'modal', inheritTheme: false }
keeps the checkout on the channel theme alone. readHostTheme and
encodeHostTheme are exported for a host that opens /embed/checkout itself.
The CDN build (dist/ticketlayer.js, served at /v1/ticketlayer.js) boots
itself from data-* attributes on its script tag; see src/auto.ts and
cdn/README.md at the repository root. CLAUDE.md is the working agreement.
Analytics
@ticketlayer/live/analytics is Ticketlayer's first-party analytics beacon,
standalone so a page that only counts visits (Manage, say) loads it alone:
about 1.5 KB gzipped, and pnpm --filter @ticketlayer/live size fails above
2 KB.
import { createAnalytics } from '@ticketlayer/live/analytics';
const analytics = createAnalytics({
endpoint: 'https://la-staging.t9r.dev/e', // production's https://la.ticketlayer.com/e when left out
key: 'tlas_...', // a channel's tlpk_ or a staff site's tlas_; omit to resolve from the Origin
surface: 'manage',
});
analytics.track('x.report_opened', { report: 'sales' });It counts a page view on start and on every History API navigation that
changes the path (autoPageviews: false turns that off), sends the time the
page was visible when it is hidden, batches up to 20 events and sends them with
sendBeacon (falling back to fetch with keepalive). It stores nothing on
the device: no cookie, no localStorage, no sessionStorage. It sends nothing
while Global Privacy Control or Do Not Track is on, or after optOut(), which
lasts for the page. URLs leave the page as origin, path and the campaign
parameters only (utm_*, ref, tl_c). A <meta name="tl-page-token"> on
the page is sent with every batch.
The Live client carries one too, keyed with its channel key: tl.analytics
and window.Ticketlayer.analytics, loaded on first use. createLiveClient's
analytics option sets endpoint, enabled, surface (element unless the
host says otherwise; the hosted box office passes hbo), hostPageTracking
(false counts the elements' events but not the page's own views) and
autoPageviews; the CDN build reads data-analytics-url,
data-analytics="false" and data-host-page-tracking="false". The Elements
record their own events through the connector's analytics (element.view,
element.open, seat.select, cart.add, checkout.step,
concierge.open), and the cart's id rides on every event while there is a
cart.
Landing context
createLiveClient reads where the visit came from once, at boot: the page
(origin, path and the campaign parameters), a referrer from another host
(origin and path), utm_*, ref and the campaign link tl_c. Only tl_c is
taken off the address; the UTM parameters stay. It is tl.landing, and every
cart the client makes is created with it, so the order can say where the sale
came from. Live keeps it on the session for the session's life.
React Native
@ticketlayer/live/native is the same stateful client (events, cart, auth,
orders, the event bus) built for an app instead of a page: nothing
defaults from window.location, nothing is published on window, no theme
is injected and no iframe modal is mounted. The Live session id travels in
the X-Live-Session header (as the browser embed's does) and is kept in an
async token storage you inject, so a cart or a magic-link sign-in survives
an app restart. Card details never enter the app: the hosted checkout opens
in a WebView.
import * as SecureStore from 'expo-secure-store';
import { createNativeLiveClient, secureStoreStorage } from '@ticketlayer/live/native';
const tl = await createNativeLiveClient({
apiUrl: 'https://api.live.ticketlayer.com', // required: no page to default from
publishableKey: 'tlpk_...',
checkoutBaseUrl: 'https://checkout.ticketlayer.com', // the host serving /embed/checkout
tokenStorage: secureStoreStorage(SecureStore), // or asyncStorageStorage(AsyncStorage)
});
const listings = await tl.events.list();
await tl.cart.add({ eventOccurrenceId, categoryId, ticketTypeId, quantity: 1 });Token storage is { get, set, remove } (all async). Two adapters ship with
the package, each taking the platform module as a parameter so the SDK
depends on neither: secureStoreStorage(SecureStore, { key?, options? })
for expo-secure-store (the keychain: preferred) and
asyncStorageStorage(AsyncStorage, { key? }) for
@react-native-async-storage/async-storage. memoryStorage() is the
default and forgets the session on restart. On start the client resumes a
stored session (GET /session); a token the API rejects is removed and the
client starts lazily, creating a session on the first cart mutation or
sign-in, as the browser client does.
Checkout, following live-hostedboxoffice/docs/embed-checkout-contract.md:
import { WebView } from 'react-native-webview';
import { Linking } from 'react-native';
import { parseCheckoutMessage } from '@ticketlayer/live/native';
const RETURN_URL = 'myapp://tickets/complete'; // on the checkout host's RETURN_URL_ALLOWLIST
// 1. The URL hands the session (and so the cart) to the hosted checkout.
const url = await tl.buildCheckoutUrl({ returnUrl: RETURN_URL });
// 2. Success arrives as a tl:complete message ...
<WebView
source={{ uri: url }}
onMessage={(e) => {
const m = parseCheckoutMessage(e.nativeEvent.data);
if (m?.type === 'tl:complete') tl.completeCheckout(m); // emits checkout:completed, drops the cart
if (m?.type === 'tl:close') close();
}}
onShouldStartLoadWithRequest={(req) => {
if (req.url.startsWith(RETURN_URL)) { Linking.openURL(req.url); return false; }
return req.url.startsWith('https://');
}}
/>
// 3. ... and as the return_url deep link (also on a cold start).
Linking.addEventListener('url', ({ url }) => {
const done = tl.parseCheckoutReturn(url, { returnUrl: RETURN_URL });
if (done) tl.completeCheckout(done); // { orderId, orderReference }
});tl.signOut() ends the session, removes the stored token and clears the
local cart; the next mutation mints a fresh session. The pure helpers
(buildCheckoutUrl, parseCheckoutReturn, parseCheckoutMessage) are
exported too for hosts that manage the session themselves.
A complete illustrative app is in examples/expo/ (type-checked against the
built package with npx tsc -p examples/expo/tsconfig.json).
Server-side rendering
@ticketlayer/live/server is a stateless catalogue client for Node and edge
runtimes: plain fetch, no DOM, no window guards, no Elements. It reads the
channel's listings, event detail and theme, keeps a small in-memory TTL cache
(60s by default, cache: false to disable), and never pulls the browser client
into your server bundle. Nothing from it is included in the browser build.
import { createServerClient } from '@ticketlayer/live/server';
export const live = createServerClient({
apiUrl: process.env.LIVE_API_INTERNAL_URL!,
// One of: a channel publishable key, or the storefront domain (resolved
// once via GET /channel and cached).
publishableKey: process.env.LIVE_PUBLISHABLE_KEY,
// domain: 'tickets.example.com',
cache: { ttlMs: 60_000 }, // or false when the framework caches for you
});A Next.js server component:
// app/events/[id]/page.tsx
import { notFound } from 'next/navigation';
import { createServerClient, LiveAPIError } from '@ticketlayer/live/server';
const live = createServerClient({
apiUrl: process.env.LIVE_API_INTERNAL_URL!,
publishableKey: process.env.LIVE_PUBLISHABLE_KEY!,
// Let Next's fetch cache own revalidation instead of the SDK's TTL cache.
cache: false,
});
export default async function EventPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
const [event, theme] = await Promise.all([
live.events.get(id, { fetchInit: { next: { revalidate: 300, tags: ['events', `event:${id}`] } } }),
live.theme.get({ fetchInit: { next: { revalidate: 300, tags: ['theme'] } } }),
]);
return (
<main style={{ fontFamily: theme.typography?.fontFamily }}>
<h1>{event.name}</h1>
</main>
);
} catch (err) {
if (err instanceof LiveAPIError && err.status === 404) notFound();
throw err;
}
}Surface:
| Call | Returns |
|------|---------|
| events.list(opts?) | Listing[] - the channel's active listings |
| events.get(eventId, opts?) | EventDetail - throws LiveAPIError (404) when not listed |
| theme.get(opts?) | Theme - throws LiveAPIError (404) when none is assigned |
| session.create() | { sessionToken, channel, customer } - a Live session id, e.g. to hand to a WebView checkout |
| get(path, opts?) | Unwrapped JSend data for any other Live GET |
| clearCache() | Drops cached reads and the domain-resolved key |
opts.fetchInit is merged into that request (headers merge, everything else
overrides), so framework-specific fetch options pass straight through.
opts.noCache bypasses the in-memory cache for one call.
Every request carries TL-Version, TL-Client and User-Agent. TL-Client
is the header every Ticketlayer SDK sets, on every runtime, because a browser
cannot set User-Agent: the fetch spec makes it a forbidden header name and
the browser drops it without saying so. This entry only ever runs in Node or on
an edge runtime, so it sets both and sends the same identity in each
(ticketlayer-live/<version> (node/<v>)). Pass userAgent to name your own
program instead; TL-Client still identifies the SDK build either way.
const live = createServerClient({
apiUrl: process.env.LIVE_API_INTERNAL_URL!,
publishableKey: process.env.LIVE_PUBLISHABLE_KEY!,
userAgent: 'acme-box-office/2.1',
});Scripts
From the repository root, after pnpm install:
pnpm --filter @ticketlayer/live build # tsc -> dist/ (index.js, server.js, native/, types)
pnpm --filter @ticketlayer/live build:cdn # + esbuild IIFE -> dist/ticketlayer.js
pnpm --filter @ticketlayer/live test # builds, then node --test test/*.test.mjs and the analytics size check
pnpm --filter @ticketlayer/live size # the analytics size check alone (after a build)
pnpm --filter @ticketlayer/live docs:gen # typedoc -> docs-output/Releasing
Versioning and publishing are driven by changesets
for the whole workspace: add a changeset with your change, and on merge to
main the release workflow keeps a "Release Ticketlayer Live" pull request
up to date; merging that publishes to npm with provenance. src/client.ts and src/generated/ are
generated by backstage/tools/sdk-tooling (backstage's sdk-regenerate.yml
opens the regeneration pull requests when the contract changes) and are
shipped exactly as committed.
The ticketlayer block in package.json records the spec version, dated API
version and generator each release was built from.
