Putiikkipalvelu Docs
SDK

Tickets

Scan and validate event tickets with the Storefront SDK

Overview

The tickets resource provides methods for event ticket scanning and validation. It is used by event staff scanner apps to authenticate, look up tickets, and mark them as used at venues.

Scanner flow:

  1. Fetch available events with tickets.events()
  2. Authenticate with a PIN via tickets.validatePin()
  3. Scan QR codes and consume tickets with tickets.use()

Tickets are created automatically when a customer purchases a ticket product. Each ticket has a unique UUID code embedded in a QR code that is emailed to the customer.


Methods

tickets.events(options?)

List events that have scanner PIN codes configured. Used to populate the event selector in a scanner app.

const { events } = await storefront.tickets.events();

Returns: Promise<TicketEventsResponse>

interface TicketEventsResponse {
  events: TicketEvent[];
}

interface TicketEvent {
  id: string;
  name: string;
  location: string;
  startDate: string | null;  // null = ongoing event
  endDate: string | null;
}

tickets.validatePin(eventId, pin, options?)

Validate a scanner PIN code for a specific event. Must be called before scanning to verify staff authorization.

const { success, event } = await storefront.tickets.validatePin(
  'clx123event',
  '1234'
);

if (success) {
  console.log(`Authenticated for: ${event.name}`);
}

Parameters:

ParamTypeRequiredDescription
eventIdstringYesEvent ID to authenticate for
pinstringYesScanner PIN code

Returns: Promise<TicketValidatePinResponse>

interface TicketValidatePinResponse {
  success: boolean;
  event: TicketEvent;
}

Throws: ValidationError if PIN is incorrect or event not found


tickets.get(code, options?)

Fetch ticket details by its unique code. Read-only lookup that does not modify the ticket.

const { ticket } = await storefront.tickets.get('550e8400-e29b-41d4-a716-446655440000');

console.log(ticket.status);       // "VALID"
console.log(ticket.productName);  // "Festival Day Pass"
console.log(ticket.usedCount);    // 0

Parameters:

ParamTypeRequiredDescription
codestringYesUnique ticket code (from QR scan)

Returns: Promise<TicketGetResponse>

Throws: NotFoundError if ticket not found


tickets.use(code, eventId, options?)

Use (consume) a ticket for a specific event. This is the core scanning endpoint.

const result = await storefront.tickets.use(
  '550e8400-e29b-41d4-a716-446655440000',
  'clx123event'
);

if (result.success) {
  console.log('Entry granted!');
  console.log(`Uses: ${result.ticket.usedCount}/${result.ticket.maxUses || '∞'}`);
} else {
  console.log(`Denied: ${result.message}`);
}

Parameters:

ParamTypeRequiredDescription
codestringYesTicket code from QR scan
eventIdstringYesEvent ID being scanned at

Returns: Promise<TicketUseResponse>

interface TicketUseResponse {
  success: boolean;
  message: string;
  ticket: PurchasedTicket;
}

Important: A success: false response is not an exception. The ticket was found but could not be used (wrong event, expired, already used). Always check the success field.

Throws: NotFoundError if ticket code does not exist


Response Types

PurchasedTicket

interface PurchasedTicket {
  id: string;
  code: string;                    // QR code value (UUID)
  status: TicketStatus;
  ticketEventId: string | null;
  firstName: string | null;        // Holder name (if requiresHolder)
  lastName: string | null;
  customerEmail: string;
  productName: string | null;      // Snapshot at purchase
  eventName: string | null;        // Snapshot at purchase
  eventDate: string | null;        // Snapshot at purchase
  price: number | null;            // In cents
  usedCount: number;
  maxUses: number;                 // 0 = unlimited
  validFrom: string;
  validUntil: string | null;       // null = no expiry
  lastUsedAt: string | null;
  createdAt: string;
}

type TicketStatus = "VALID" | "USED" | "EXPIRED" | "CANCELLED";

Ticket Status Meanings

StatusDescription
VALIDTicket can be used
USEDTicket has reached its maximum uses
EXPIREDTicket's validUntil date has passed
CANCELLEDTicket was cancelled (e.g., refund)

Usage Limits

maxUsesBehavior
0Unlimited uses (season pass / day pass)
1Single entry (status becomes USED after one scan)
NMulti-use (N entries allowed)

Ticket Products in the Storefront

Ticket products are regular products with a ticketInfo field. This field is included on the product detail endpoint (products.getBySlug()), not on listings.

const product = await storefront.products.getBySlug('summer-festival-pass');

if (product.ticketInfo) {
  // This is a ticket product
  console.log(product.ticketInfo.requiresHolder);  // true/false
  console.log(product.ticketInfo.maxUses);          // 1 = single entry
}
interface TicketInfoSummary {
  id: string;
  requiresHolder: boolean;  // Whether holder names are required at checkout
  maxUses: number;           // 0 = unlimited, 1 = single entry
}

Ticket Checkout

When purchasing ticket products, the checkout has special behavior:

  1. No shipping required — Ticket-only orders skip shipping entirely. Pass shipmentMethod: null.
  2. Holder names — If ticketInfo.requiresHolder is true, you must provide ticketHolders in the checkout params (one name per ticket quantity).
  3. Sales window — Tickets can only be purchased within their salesStart / salesEnd window. The API returns TICKET_SALES_NOT_STARTED or TICKET_SALES_ENDED errors.

Checkout with Ticket Holders

import { randomUUID } from "crypto";

const orderId = randomUUID();

const { url } = await storefront.checkout.stripe({
  customerData: {
    first_name: "Matti",
    last_name: "Meikalainen",
    email: "matti@example.fi",
    address: "Mannerheimintie 1",
    postal_code: "00100",
    city: "Helsinki",
    phone: "+358401234567",
  },
  shipmentMethod: null,  // No shipping for ticket-only orders
  orderId,
  successUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/payment/success/${orderId}`,
  cancelUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/payment/cancel/${orderId}`,
  ticketHolders: {
    "product_id_here": [
      { firstName: "Matti", lastName: "Meikalainen" },
      { firstName: "Maija", lastName: "Meikalainen" },
    ],
  },
}, { cartId });

Checkout Error Codes for Tickets

CodeDescription
TICKET_SALES_NOT_STARTEDTicket sales haven't started yet
TICKET_SALES_ENDEDTicket sales have ended
TICKET_HOLDER_REQUIREDHolder names missing for a requiresHolder ticket

Examples

Scanner App — Complete Flow

import { createStorefrontClient, NotFoundError } from "@putiikkipalvelu/storefront-sdk";

const client = createStorefrontClient({
  apiKey: process.env.STOREFRONT_API_KEY!,
  baseUrl: process.env.STOREFRONT_API_URL!,
});

// 1. List available events
const { events } = await client.tickets.events();
// → Display event selector to staff

// 2. Authenticate with PIN
const selectedEventId = events[0].id;
const { success } = await client.tickets.validatePin(selectedEventId, "1234");

if (!success) {
  console.error("Invalid PIN");
  return;
}

// 3. Scan tickets (called on each QR code scan)
async function handleScan(qrCode: string) {
  try {
    const result = await client.tickets.use(qrCode, selectedEventId);

    if (result.success) {
      // Show green checkmark
      return {
        allowed: true,
        name: `${result.ticket.firstName ?? ""} ${result.ticket.lastName ?? ""}`.trim(),
        uses: `${result.ticket.usedCount}/${result.ticket.maxUses || "∞"}`,
      };
    } else {
      // Show red X with reason
      return {
        allowed: false,
        reason: result.message,
        status: result.ticket.status,
      };
    }
  } catch (error) {
    if (error instanceof NotFoundError) {
      return { allowed: false, reason: "Unknown ticket" };
    }
    throw error;
  }
}

Detecting Ticket Products on Storefront

import { storefront } from "@/lib/storefront";

export default async function ProductPage({ params }: { params: { slug: string } }) {
  const product = await storefront.products.getBySlug(params.slug);

  const isTicket = !!product.ticketInfo;

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{(product.price / 100).toFixed(2)} &euro;</p>

      {isTicket && (
        <p>
          {product.ticketInfo!.maxUses === 1
            ? "Single entry ticket"
            : product.ticketInfo!.maxUses === 0
              ? "Unlimited entry pass"
              : `${product.ticketInfo!.maxUses}-use ticket`}
        </p>
      )}
    </div>
  );
}

Error Handling

import { NotFoundError, ValidationError, StorefrontError } from "@putiikkipalvelu/storefront-sdk";

// Scanning errors
try {
  const result = await storefront.tickets.use(code, eventId);
} catch (error) {
  if (error instanceof NotFoundError) {
    // Ticket code doesn't exist
    return { error: "Unknown ticket" };
  }
  if (error instanceof StorefrontError) {
    return { error: error.message };
  }
  throw error;
}

// Checkout errors for ticket products
try {
  const { url } = await storefront.checkout.stripe(params, options);
} catch (error) {
  if (error instanceof ValidationError) {
    if (error.message.includes("TICKET_SALES_NOT_STARTED")) {
      return { error: "Ticket sales haven't started yet" };
    }
    if (error.message.includes("TICKET_SALES_ENDED")) {
      return { error: "Ticket sales have ended" };
    }
    if (error.message.includes("TICKET_HOLDER_REQUIRED")) {
      return { error: "Please fill in holder names for all tickets" };
    }
  }
  throw error;
}

On this page