# Resyevents Ticketing (/developers/resyevents)



`resyevents.js` sells tickets to [live events](/docs/live-events) on your own site: browse a venue's events, reserve tickets, take payment, and confirm the order. Everything the [Resyevents storefront](/docs/live-events/storefront) does, on your domain.

<Callout type="info">
  This is a **separate library** from `resytech.js`. It has its own bundle, its own class, and shares no code with the booking client — so a ticketing page never downloads the booking library, and the two can sit on the same page without colliding.
</Callout>

Quick Start [#quick-start]

```html
<script src="https://js.resytech.com/latest/resyevents.js"></script>

<resyevents-ticket-picker
  event-slug="summer-block-party"
  checkout-url="/tickets/checkout">
</resyevents-ticket-picker>

<script>
  const events = new ResyeventsApi();
  events.registerComponents();
</script>
```

There is no `initialize()` call and no access token. Every endpoint on the ticketing API is anonymous — the event slug is all you need.

Events Are Not Bookings [#events-are-not-bookings]

Live events are a different product on a different host, and nothing from `ResytechApi` applies:

|          | Booking (`resytech.js`)       | Ticketing (`resyevents.js`)                     |
| -------- | ----------------------------- | ----------------------------------------------- |
| Class    | `ResytechApi`                 | `ResyeventsApi`                                 |
| Session  | `initialize()` + bearer token | None — anonymous                                |
| Keyed by | Location ID + activity UUID   | Global event slug (`/e/{slug}`)                 |
| Cart     | Shopping cart                 | Inventory **holds** (\~10 min)                  |
| Payment  | Platform charge               | Direct charge on the organizer's Stripe account |

You can load both libraries on one page. They define separate globals and share no symbols.

The Buying Flow [#the-buying-flow]

1. **Browse** — list a venue's events, or fetch one event with its ticket tiers, add-ons, and live availability.
2. **Reserve** — the buyer's picks become server-side **holds** that take that inventory out of circulation for about 10 minutes, so two buyers racing for the last tickets can never both win.
3. **Check out** — the holds are priced into an order and paid by card in the browser.
4. **Confirm** — fulfillment happens on a payment webhook, so the order becomes `Paid` a moment *after* the card clears. Poll for it. The tickets themselves are emailed to the buyer.

<Callout type="warn">
  A successful card payment means **paid**, not **ticketed**. Never tell a buyer their tickets exist based on the checkout response alone — poll the order until it settles, or send them to a page that does.
</Callout>

Web Components [#web-components]

The fastest path. Three pages, or all on one — the cart survives navigation via `sessionStorage`.

```html
<!-- A venue's upcoming events -->
<resyevents-list
  venue-slug="the-warehouse"
  limit="12"
  href-pattern="/tickets/{slug}">
</resyevents-list>

<!-- Pick tickets, then send the buyer to your checkout page -->
<resyevents-ticket-picker
  event-slug="summer-block-party"
  checkout-url="/tickets/checkout">
</resyevents-ticket-picker>

<!-- Buyer details, payment, order creation -->
<resyevents-checkout
  return-url="/tickets/confirmed"
  back-url="/tickets/summer-block-party">
</resyevents-checkout>

<!-- Polls until the order settles, then shows the confirmation -->
<resyevents-order></resyevents-order>
```

`<resyevents-order>` reads `?order=` from the URL, which is exactly what `<resyevents-checkout return-url="…">` appends — so those two need no wiring between them.

Components [#components]

| Component     | Tag                          | Purpose                                       |
| ------------- | ---------------------------- | --------------------------------------------- |
| Event Card    | `<resyevents-card>`          | One event as a card                           |
| Event List    | `<resyevents-list>`          | A venue's events, paginated                   |
| Ticket Picker | `<resyevents-ticket-picker>` | Tiers, add-ons, and the reserve action        |
| Checkout      | `<resyevents-checkout>`      | Buyer details, Stripe payment, order creation |
| Order         | `<resyevents-order>`         | Confirmation, polling until the order settles |

Attributes [#attributes]

| Component                    | Attributes                                                                              |
| ---------------------------- | --------------------------------------------------------------------------------------- |
| `<resyevents-card>`          | `event-slug`, `href-pattern`, `show-venue`, `show-price`                                |
| `<resyevents-list>`          | `venue-slug`, `limit`, `past`, `href-pattern`, `show-title`, `title-text`, `empty-text` |
| `<resyevents-ticket-picker>` | `event-slug`, `checkout-url`, `show-event-header`, `cta-text`                           |
| `<resyevents-checkout>`      | `return-url`, `back-url`, `event-slug`                                                  |
| `<resyevents-order>`         | `order-uuid`, `query-param`                                                             |

`href-pattern` takes `{slug}` and `{uuid}` placeholders and turns each card into a real link. Leave it off and the card emits `event-select` instead.

Events [#events]

```javascript
document.addEventListener('tickets-reserved', (e) => {
  console.log('Held until', e.detail.holds[0].expiresAt);
});
```

| Event               | Fired By      | Detail                                  |
| ------------------- | ------------- | --------------------------------------- |
| `event-select`      | Card, List    | `{ slug, uuid }`                        |
| `events-loaded`     | List          | `{ events, totalCount }`                |
| `selection-change`  | Ticket Picker | `{ quantities, ticketCount, subtotal }` |
| `tickets-reserved`  | Ticket Picker | `{ eventUuid, slug, holds }`            |
| `reserve-failed`    | Ticket Picker | `{ reason, message }`                   |
| `checkout-complete` | Checkout      | `{ orderUuid, grandTotal, status }`     |
| `checkout-failed`   | Checkout      | `{ error, message }`                    |
| `holds-expired`     | Checkout      | `{}`                                    |
| `order-settled`     | Order         | `{ outcome, order }`                    |

Theming [#theming]

Components use the event's own [theme](/docs/live-events/themes) when the organizer set one, falling back to a neutral light palette — so an unthemed event does not drop a dark panel onto your page. Override with CSS custom properties:

```html
<resyevents-ticket-picker
  event-slug="summer-block-party"
  style="--re-accent: #e11d48; --re-radius: 8px;">
</resyevents-ticket-picker>
```

| Property            | Description                | Default      |
| ------------------- | -------------------------- | ------------ |
| `--re-bg`           | Page background            | `#ffffff`    |
| `--re-surface`      | Cards and panels           | `#ffffff`    |
| `--re-surface-2`    | Inputs and placeholders    | `#f8fafc`    |
| `--re-border`       | Borders and dividers       | `#e2e8f0`    |
| `--re-fg`           | Primary text               | `#0f172a`    |
| `--re-muted`        | Secondary text             | `#64748b`    |
| `--re-accent`       | Buttons, links, highlights | `#2563eb`    |
| `--re-accent-hover` | Button hover               | `#1d4ed8`    |
| `--re-accent-fg`    | Text on accent surfaces    | `#ffffff`    |
| `--re-success`      | Confirmation check         | `#16a34a`    |
| `--re-danger`       | Errors, "Only N left"      | `#dc2626`    |
| `--re-radius`       | Corner radius              | `12px`       |
| `--re-font`         | Font family                | System stack |

Each component also exposes `part` names (`container`, `card`, `row`, `summary`, `cta`, `totals`, `tickets`) for `::part()` styling.

Building Your Own UI [#building-your-own-ui]

`events.cart` holds the session and hold protocol so you do not have to reimplement it.

```javascript
const events = new ResyeventsApi();

const { event } = await events.event.getEvent('summer-block-party');
events.cart.begin(event.uuid, event.slug);

// All-or-nothing: if any hold fails, the ones already taken are released for you.
const result = await events.cart.reserve([
  { kind: 'ticket', uuid: tier.uuid,  name: tier.name,  price: tier.price,  quantity: 2 },
  { kind: 'addon',  uuid: addon.uuid, name: addon.name, price: addon.price, quantity: 1 },
]);

if (!result.success) {
  showError(result.message);   // already readable: 'Only 2 left for "VIP".'
  return;
}

const quote = await events.cart.quote();   // real fees and taxes

const checkout = await events.cart.checkout({
  buyerName: 'Alex Chen',
  buyerEmail: 'alex@example.com',
});
```

| Member                    | Purpose                                                   |
| ------------------------- | --------------------------------------------------------- |
| `begin(uuid, slug)`       | Point the cart at an event, discarding a different one    |
| `reserve(selections)`     | Create holds, all-or-nothing                              |
| `quote()`                 | Price the holds — real fees and taxes                     |
| `checkout(details)`       | Turn the holds into a payable order                       |
| `releaseAll()`            | Free the holds (a "back to tickets" action)               |
| `ticketCount`, `subtotal` | Running totals                                            |
| `earliestExpiry`          | Epoch ms of the soonest hold expiry, for a countdown      |
| `attendeeSlots`           | One label per ticket, in the order names must be supplied |
| `hasActiveHoldsFor(uuid)` | Whether a checkout page has something to check out        |

Taking Payment [#taking-payment]

Event charges are **direct charges on the organizer's connected Stripe account**, so Stripe.js must be scoped to it:

```javascript
if (checkout.success && checkout.status === 'paid') {
  // A free order — already fulfilled, no payment step.
} else if (checkout.success) {
  const stripe = Stripe(publishableKey, { stripeAccount: checkout.stripeAccountId });
  const elements = stripe.elements({ clientSecret: checkout.clientSecret });
  // …mount the Payment Element and confirm
}
```

<Callout type="warn">
  Use `checkout.isTestMode` to pick which publishable key to load. Pairing a live key with a test-mode connected account makes `Stripe()` fail and the Payment Element never mounts.
</Callout>

Rules the API Enforces [#rules-the-api-enforces]

Each of these fails quietly if you ignore it. `events.cart` handles all of them — you only need them when calling `events.holds` directly.

**Mint a session before holding anything.** `events.holds.createSession()`. The server does not create one implicitly; a request without a token used to get a brand-new session every time, which defeated the per-session hold limit.

**Store the `sessionToken` from every response that returns one.** The server refreshes it once it is past half its life, to keep an active buyer from lapsing. Ignoring the refreshed value works right up until the original expires — at which point the cart dies mid-checkout with live holds still holding inventory.

**Drop a rejected session, do not retry it.** `INVALID_SESSION_TOKEN` is reported separately from `INVALID_REQUEST` precisely so you can tell "the session is dead" from "the email is malformed".

**Turnstile tokens are single use.** Reset the widget after every submit attempt. A challenge only applies to events with a free tier; the site key arrives on the event as `captchaSiteKey`, and can also arrive later on a `CAPTCHA_FAILED` response if the gate armed mid-session.

Quantity Rules [#quantity-rules]

The picker enforces the same limits as the Resyevents storefront, so an embedded picker and the public page behave identically for the same event:

* A tier with a **minimum per order** must be bought at or above that minimum.
* The stepper is capped by whichever is lower: the organizer's per-order maximum, or what is actually left. With no maximum set, one order tops out at **10** of that tier.
* At **8 or fewer** remaining, an "Only N left" line appears.
* Add-on steppers stay disabled until at least one ticket is selected — nobody can buy a drink package with no admission.

Availability is a snapshot: another buyer can take the last ticket between the page load and the hold. Show `available` as guidance and let `reserve()` settle it — on failure the picker re-reads the event so the buyer sees what is truly left.

Failure Codes [#failure-codes]

Failures come back with a machine-readable code and readable text already in `message`. `describeHoldFailure()` and `describeCheckoutFailure()` are exported if you want your own wording.

| Code                      | Meaning                                                       |
| ------------------------- | ------------------------------------------------------------- |
| `INSUFFICIENT_INVENTORY`  | Someone else took them first                                  |
| `EXCEEDS_MAX_PER_ORDER`   | Over the tier's per-order limit                               |
| `SESSION_HOLD_LIMIT`      | This cart already holds as much as one cart may               |
| `NOT_ON_SALE`             | Outside the sale window                                       |
| `TIER_INACTIVE`           | The organizer deactivated the tier                            |
| `INVALID_SESSION_TOKEN`   | Session expired or rejected — mint a new one                  |
| `NO_ACTIVE_HOLDS`         | The holds lapsed before checkout                              |
| `ORGANIZER_NOT_READY`     | Stripe onboarding incomplete — hide the buy flow              |
| `CAPTCHA_FAILED`          | Turnstile challenge not passed                                |
| `ATTENDEE_NAMES_REQUIRED` | The event needs a name per ticket                             |
| `MAX_PER_EMAIL`           | This email hit the event's per-buyer limit                    |
| `PAYMENT_SETUP_FAILED`    | The charge could not be opened; `message` has Stripe's reason |

Dates and Time Zones [#dates-and-time-zones]

**Timestamps are ISO strings, not `Date` objects**, and every event carries its own `timeZone` — which is not the browser's. A doors-open time must read as the local time at the venue no matter where the buyer is sitting.

```javascript
import { formatRange, formatDay, formatTime, asUtcDate } from 'resytech-client-lib/resyevents';

formatRange(event.startAt, event.endAt, event.timeZone);
// "Sat, Sep 12, 2026 · 7:00 PM – 11:00 PM EDT"
```

Formatting with plain `new Date(...).toLocaleString()` shifts every event by the viewer's offset.

API Reference [#api-reference]

```javascript
// Discovery
await events.event.getEvent(slug);
await events.venue.getVenue(slug);
await events.venue.getEvents(slug, { past: false, limit: 12, offset: 0 });

// Cart — the intended entry point
events.cart.begin(eventUuid, eventSlug);
await events.cart.reserve(selections);
await events.cart.quote();
await events.cart.checkout({ buyerEmail, buyerName, attendeeNames, captchaToken });
await events.cart.releaseAll();

// Raw endpoints — escape hatch
await events.holds.createSession();
await events.holds.holdTickets({ ticketTypeUuid, quantity, sessionToken });
await events.holds.holdAddon({ addonUuid, quantity, sessionToken });
await events.holds.releaseHold(holdUuid, sessionToken);
await events.checkout.quote({ eventUuid, sessionToken });
await events.checkout.checkout({ eventUuid, sessionToken, buyerEmail });

// Order
await events.order.getOrder(orderUuid);
await events.order.pollUntilSettled(orderUuid, { intervalMs, maxAttempts, onUpdate, signal });
```

Every response carries the same envelope as the booking client — `success`, `message`, `statusCode`, `action`, `isNetworkError` — plus its payload. Nothing throws: a network failure comes back as `success: false` with `isNetworkError: true`. See [Error Handling](/developers/error-handling).

Configuration [#configuration]

```javascript
const events = new ResyeventsApi({
  debug: true,
  timeout: 30000,
  cartStorageKey: 'resyevents_cart',
});
```

| Option           | Default                         | Description                                                  |
| ---------------- | ------------------------------- | ------------------------------------------------------------ |
| `baseUrl`        | `https://api.resyevents.com/v1` | Ticketing API host                                           |
| `debug`          | `false`                         | Console logging                                              |
| `timeout`        | `30000`                         | Request timeout in ms                                        |
| `headers`        | —                               | Custom headers on every request                              |
| `cartStorageKey` | `resyevents_cart`               | `sessionStorage` key; change it to run two carts on one page |

Auto-Initialization [#auto-initialization]

```html
<script src="https://js.resytech.com/latest/resyevents.js" data-resyevents-auto="true"></script>
```

Creates the client, registers the components, and exposes it as `window.resyevents`. Without the attribute nothing is created and no request is made.

| Attribute                  | Required | Description                         |
| -------------------------- | -------- | ----------------------------------- |
| `data-resyevents-auto`     | Yes      | Set to `"true"` to auto-initialize  |
| `data-resyevents-debug`    | No       | Set to `"true"` for console logging |
| `data-resyevents-base-url` | No       | Override the API host               |

Security Notes [#security-notes]

* **An order UUID is a bearer credential.** It grants access to that order — buyer details and the admission tokens. Keep it out of anything indexable, cacheable, or shareable, and off analytics query strings.
* **Admission QR codes are emailed, never rendered by these components.** That is deliberate; a confirmation page is a far easier thing to screenshot and forward than an inbox.
* **`chargesEnabled: false`** means the organizer has not finished Stripe onboarding. Hide the buy flow rather than letting the buyer fail at the last step — the ticket picker does this for you.

Next Steps [#next-steps]

* [Live Events (operator guide)](/docs/live-events) — creating events, tiers, venues, payouts
* [The Storefront](/docs/live-events/storefront) — what the public page does, which these components mirror
* [Error Handling](/developers/error-handling) — the response envelope
