Seatmap-complete-guide

Complete Seatmap Websockets and Client Library Guide

This guide provides comprehensive documentation for using the Betterez seatmap websockets system, including authentication, connection setup, event handling, and integration with the client-side library (btrz-seatmaps).

Table of Contents

  1. Overview
  2. Architecture
  3. Authentication
  4. Getting Seatmap Data
  5. Websocket Connection
  6. Channel Events
  7. Client Library (btrz-seatmaps)
  8. API-Sales Integration
  9. Complete Examples
  10. Troubleshooting

Overview

The Betterez seatmap websocket system enables real-time synchronization of seat selections across multiple clients viewing the same seatmap. When a user selects or unselects a seat, all other connected clients are notified immediately, preventing double-booking and providing a seamless user experience.

Key Components

  • Seatmaps api: Elixir/Phoenix backend service that manages websocket connections and seat state
  • btrz-seatmaps: JavaScript client library that provides a high-level API for connecting to websockets and managing seatmap UI
  • Sales api: Receives seat information when bookings are completed

How It Works

  1. Client fetches seatmap configuration and available seats from the Inventory API
  2. Client requests an access ticket from the seatmaps API (server-side)
  3. Client connects to the websocket using the access ticket
  4. Client joins a channel for a specific seatmap and leg range
  5. Client receives initial state of selected seats (sync:join)
  6. Client sends/receives seat selection events in real-time
  7. Server broadcasts events to all connected clients
  8. Expired seats are automatically cleaned up and broadcasted

Architecture

Server-Side (btrz-api-seatmaps)

The websocket server is built on Phoenix Channels and removes expired seats every second and broadcast updates.

Client-Side (btrz-seatmaps)

The client library provides:

  • SeatmapSocket: Manages websocket connections and channel subscriptions
  • SeatmapSection: Renders and manages the seatmap UI
  • Phoenix Socket: Embedded Phoenix.js library for websocket communication

Data Flow

Client A selects seat → Channel push → Server → Redis cache → Broadcast to all clients
                                                              ↓
Client B receives event → Updates UI → Seat appears blocked

Authentication

Step 1: Get Access Ticket (Server-Side)

Before connecting to the websocket, you must obtain an access ticket from the seatmaps API. This must be done server-side to protect your JWT token.

Endpoint: POST /seatmaps/access-ticket

Headers:

  • X-API-KEY: Your API key
  • Authorization: Bearer {jwtToken}

Example (Node.js with axios):

const axios = require('axios');

async function getAccessTicket(apiKey, jwtToken) {
  try {
    const response = await axios({
      url: 'https://api.betterez.com/seatmaps/access-ticket',
      method: 'post',
      headers: {
        'X-API-KEY': apiKey,
        'Authorization': `Bearer ${jwtToken}`
      }
    });
    
    return response.data.ticket; // This is the access ticket
  } catch (error) {
    console.error('Error getting access ticket:', error);
    throw error;
  }
}

Response:

{
  "ticket": "SFMyNTY.g3QAAAACbQAAAAt1c2VyX2lkZW50aXR5..."
}

Token Expiration: The ticket is a Phoenix.Token signed token with the following expiration times:

  • Production/Sandbox: 30 minutes (1800 seconds)
  • Test: 2 seconds

After the token expires, you will need to request a new access ticket to establish new websocket connections.

Important Considerations:

  • Request a new access ticket before it expires if you need to maintain long-lived connections
  • If a token expires while connected, the connection may be terminated
  • Plan to refresh tokens proactively for applications with extended session times

Step 2: Connect to Websocket

The access ticket is used to authenticate the websocket connection.

Important:

  • The ticket has an expiration time of 30 minutes (in production/sandbox environments)
  • If verification fails (expired or invalid token), the connection will be rejected with a 403 error
  • The token is passed as a parameter when creating the socket connection
  • You should request a new access ticket before the current one expires if you need to reconnect

Getting Seatmap Data

Before connecting to the websocket, you need to fetch the seatmap configuration and available seats from the Inventory API. This endpoint provides the seatmap structure (sections, rows, seats) and indicates which seats are already occupied or unavailable.

Endpoint: Get Available Seats

Endpoint: GET /inventory/seatmaps/{seatmapId}/available-seats/{routeId}/{scheduleId}/{manifestDate}

Base URL:

  • Production: https://api.betterez.com
  • Sandbox: https://sandbox-api.betterez.com

Path Parameters:

  • seatmapId (String, required): The ID of the seatmap
  • routeId (String, required): The ID of the route
  • scheduleId (String, required): The ID of the schedule (also referred to as schedule or name)
  • manifestDate (String, required): The date of travel in format YYYY-MM-DD

Query Parameters:

  • providerId (String, required): The ID of the provider account
  • originId (String, required): The ID of the origin/departure station
  • destinationId (String, required): The ID of the destination/arrival station
  • legFromIndex (Number, required): The leg index for the departing station (0-based)
  • legToIndex (Number, required): The leg index for the arrival station (0-based)
  • newdesign (Boolean, optional): Set to true to use the new seatmap design format. Default: false
  • channel (String, optional): The sales channel (e.g., "websales", "backoffice", "callcenter")

Headers:

  • X-API-KEY: Your API key
  • Authorization: Bearer {jwtToken} (optional, depending on your authentication setup)

Example Request:

const axios = require('axios');

async function getSeatmapData(seatmapId, routeId, scheduleId, manifestDate, options) {
  const {
    providerId,
    originId,
    destinationId,
    legFromIndex,
    legToIndex,
    newdesign = true,
    channel,
    apiKey,
    jwtToken
  } = options;

  const url = `https://api.betterez.com/inventory/seatmaps/${seatmapId}/available-seats/${routeId}/${scheduleId}/${manifestDate}`;
  
  const params = {
    providerId,
    originId,
    destinationId,
    legFromIndex,
    legToIndex,
    newdesign,
    ...(channel && { channel })
  };

  const headers = {
    'X-API-KEY': apiKey
  };

  if (jwtToken) {
    headers['Authorization'] = `Bearer ${jwtToken}`;
  }

  try {
    const response = await axios.get(url, {
      params,
      headers
    });
    
    return response.data;
  } catch (error) {
    console.error('Error fetching seatmap:', error);
    throw error;
  }
}

// Usage
const seatmapData = await getSeatmapData(
  '5c90f854d99e27e20d000001',
  'route-id-123',
  'schedule-id-456',
  '2022-12-08',
  {
    providerId: 'provider-id-789',
    originId: 'origin-station-id',
    destinationId: 'destination-station-id',
    legFromIndex: 0,
    legToIndex: 1,
    newdesign: true,
    channel: 'websales',
    apiKey: 'your-api-key',
    jwtToken: 'your-jwt-token'
  }
);

Response Structure:

{
  "seatmap": {
    "_id": "5c90f854d99e27e20d000001",
    "name": "Bus Seatmap",
    "sections": [
      {
        "_id": "5c13c12d062bb938026c3161",
        "name": "Left",
        "availableRows": 15,
        "seatsPerRowLeft": 2,
        "seatsPerRowRight": 2,
        "seats": [
          {
            "row": 1,
            "col": 0,
            "status": "available",
            "sectionId": "5c13c12d062bb938026c3161",
            "fee": "fee-id-123",
            "seatClass": "class-id-456"
          }
        ],
        "customSeats": []
      }
    ],
    "customSeats": []
  }
}

Response Field Descriptions:

  • seatmap (Object, required): The seatmap configuration object
    • _id (String): The seatmap ID
    • name (String): Human-readable seatmap name
    • sections (Array): Array of seatmap sections
      • Each section object contains:
        • _id (String): Section ID
        • name (String): Section name (e.g., "Left", "Right", "Center")
        • availableRows (Number): Number of rows in the section
        • seatsPerRowLeft (Number): Number of seats on the left side of the aisle
        • seatsPerRowRight (Number): Number of seats on the right side of the aisle
        • seats (Array): Array of seat objects in this section
          • Each seat object contains:
            • row (Number): Row number (1-based)
            • col (Number): Column number (0-based)
            • status (String): Seat status - "available", "reserved", "blocked", or "taken"
            • sectionId (String): ID of the section containing this seat
            • fee (String, optional): Seat fee ID if a fee is configured for this seat
            • seatClass (String, optional): Seat class ID if seat classes are configured
        • customSeats (Array): Array of custom seat configurations (for special seats like wheelchair spaces)
    • customSeats (Array): Global custom seats array

Important Notes:

  • This endpoint returns the seatmap structure and current availability status
  • Seats marked as "reserved" or "taken" are already booked and should not be selectable
  • Seats marked as "blocked" are administratively blocked
  • The status field indicates the current state, but real-time updates come through websockets
  • Use this data to initialize your seatmap UI before connecting to websockets
  • The websocket sync:join event will provide additional real-time selected seats that may not be reflected in this initial response

Seat Fees and Seat Classes

The seatmap response includes fee and seatClass IDs for seats that have fees or classes configured. To display fee and class information in your UI, you need to fetch the detailed information from the Inventory API.

Fetching Seat Fees

Endpoint: GET /inventory/seatfees

Query Parameters:

  • providerId (String, required): The ID of the provider account

Response:

{
  "seatfees": [
    {
      "_id": "fee-id-123",
      "name": "Window Seat",
      "value": 5.00,
      "type": "$",
      "currency": "USD"
    },
    {
      "_id": "fee-id-456",
      "name": "Extra Legroom",
      "value": 10,
      "type": "%",
      "currency": "USD"
    }
  ]
}

Field Descriptions:

  • _id (String): Seat fee ID (matches the fee field in seat objects)
  • name (String): Human-readable fee name
  • value (Number): Fee amount or percentage
  • type (String): Fee type - "$" for fixed amount, "%" for percentage
  • currency (String): Currency code

Displaying Seat Fees in UI:

// Fetch seat fees
const seatFees = await fetchSeatFees(providerId);

// When rendering a seat with a fee
function renderSeatWithFee(seat, seatFees) {
  const fee = seatFees.find(f => f._id === seat.fee);
  
  if (fee) {
    const feeDisplay = fee.type === '$' 
      ? `+$${fee.value}` 
      : `+${fee.value}%`;
    
    // Display fee information in your UI
    return {
      seatId: seat.seatId,
      feeName: fee.name,
      feeValue: feeDisplay,
      feeId: fee._id
    };
  }
  
  return { seatId: seat.seatId };
}

Fetching Seat Classes

Endpoint: GET /inventory/seatclasses

Query Parameters:

  • providerId (String, required): The ID of the provider account

Response:

{
  "seatclasses": [
    {
      "_id": "class-id-123",
      "name": "Premium",
      "value": "Premium Class"
    },
    {
      "_id": "class-id-456",
      "name": "Standard",
      "value": "Standard Class"
    }
  ]
}

Field Descriptions:

  • _id (String): Seat class ID (matches the seatClass field in seat objects)
  • name (String): Seat class identifier
  • value (String): Human-readable seat class name

Displaying Seat Classes in UI:

// Fetch seat classes
const seatClasses = await fetchSeatClasses(providerId);

// When rendering a seat with a class
function renderSeatWithClass(seat, seatClasses) {
  const seatClass = seatClasses.find(sc => sc._id === seat.seatClass);
  
  if (seatClass) {
    // Display seat class information in your UI
    return {
      seatId: seat.seatId,
      seatClassName: seatClass.value,
      seatClassId: seatClass._id
    };
  }
  
  return { seatId: seat.seatId };
}

Complete Example: Rendering Seat with Fee and Class

async function initializeSeatmapWithFeesAndClasses(seatmapData, providerId) {
  // Fetch fees and classes
  const [seatFees, seatClasses] = await Promise.all([
    fetchSeatFees(providerId),
    fetchSeatClasses(providerId)
  ]);
  
  // Create a map for quick lookup
  const feeMap = new Map(seatFees.map(f => [f._id, f]));
  const classMap = new Map(seatClasses.map(c => [c._id, c]));
  
  // Render each seat with fee/class information
  seatmapData.seatmap.sections.forEach(section => {
    section.seats.forEach(seat => {
      const seatInfo = {
        row: seat.row,
        col: seat.col,
        sectionId: seat.sectionId,
        status: seat.status
      };
      
      // Add fee information if present
      if (seat.fee && feeMap.has(seat.fee)) {
        const fee = feeMap.get(seat.fee);
        seatInfo.feeId = fee._id;
        seatInfo.feeName = fee.name;
        seatInfo.feeValue = fee.type === '$' 
          ? `+$${fee.value}` 
          : `+${fee.value}%`;
      }
      
      // Add class information if present
      if (seat.seatClass && classMap.has(seat.seatClass)) {
        const seatClass = classMap.get(seat.seatClass);
        seatInfo.seatClassId = seatClass._id;
        seatInfo.seatClassName = seatClass.value;
      }
      
      // Render seat in UI with all information
      renderSeat(seatInfo);
    });
  });
}

Integration with Websockets:

  1. First, call this endpoint to get the seatmap structure and initial seat states
  2. Render the seatmap UI using the returned data
  3. Connect to the websocket and join the channel
  4. Handle the sync:join event to update seats that are selected in real-time (but not yet booked)
  5. Use websocket events to keep the seatmap synchronized as users select/unselect seats

Websocket Connection

Connection URL

The websocket endpoint is available at:

  • Production: wss://api.betterez.com/seatmaps/socket
  • Sandbox: wss://sandbox-api.betterez.com/seatmaps/socket

Domain Whitelisting

Important: You cannot connect from localhost. You must:

  1. Deploy your application to a well-known server
  2. Provide the domains to the Betterez support team
  3. Domains must be under HTTPS
  4. Support team will whitelist domains for sandbox and production

Basic Connection Setup

import { Socket } from 'phoenix';

const socketUrl = 'wss://api.betterez.com/seatmaps/socket';
const accessTicket = 'your-access-ticket-here'; // Obtained server-side

// Create socket connection
const socket = new Socket(socketUrl, {
  params: { token: accessTicket }
});

// Connect to socket
socket.connect();

// Handle connection events
socket.onError((err) => {
  console.error('Socket error:', err);
});

socket.onOpen(() => {
  console.log('Socket connected');
});

socket.onClose(() => {
  console.log('Socket closed');
});

Channel Join

After connecting to the socket, you need to join a specific seatmap channel.

Channel Topic Format: seatmap:{seatmapId}

Where seatmapId is constructed as: {scheduleId}_{date}

Example: seatmap:5c90f854d99e27e20d000001_2022-12-08

Join Parameters:

  • leg_from (Number): The leg index for the departing station (must be >= 0)
  • leg_to (Number): The leg index for the arrival station (must be >= 0)
const scheduleId = '5c90f854d99e27e20d000001';
const date = '2022-12-08';
const seatmapId = `${scheduleId}_${date}`;
const legFrom = 0;
const legTo = 1;

// Create channel
const channel = socket.channel(`seatmap:${seatmapId}`, {
  leg_from: legFrom,
  leg_to: legTo
});

// Join channel
channel.join()
  .receive('ok', () => {
    console.log('Successfully joined channel');
  })
  .receive('error', (err) => {
    console.error('Failed to join channel:', err.reason);
  })
  .receive('timeout', () => {
    console.error('Join timeout');
  });

Leg Filtering: The server filters seat events based on the leg range. Only seats that overlap with your leg_from and leg_to range will be sent to your client. This allows multiple clients to view different segments of the same trip without receiving irrelevant events.


Channel Events

Incoming Events (Server → Client)

1. sync:join

Sent immediately after successfully joining a channel. Contains all currently selected seats for the seatmap.

Payload:

{
  "seats": [
    {
      "leg_from": 0,
      "leg_to": 1,
      "seat_id": "section-5c13c12d062bb938026c3161-row-1-seat-0",
      "seat": {
        "row": 1,
        "col": 0,
        "sectionId": "5c13c12d062bb938026c3161",
        "scheduleId": "5c90f854d99e27e20d000001"
      }
    }
  ]
}

Field Descriptions:

  • seats (Array, required): Array of currently selected seat objects
    • Each seat object contains:
      • leg_from (Number, required): Leg index for the departing station (0-based)
      • leg_to (Number, required): Leg index for the arrival station (0-based)
      • seat_id (String, required): Unique seat identifier in format section-{sectionId}-row-{row}-seat-{col}
      • seat (Object, optional): Additional seat information object that may contain:
        • row (Number): Row number in the seatmap
        • col (Number): Column number in the seatmap
        • sectionId (String): ID of the section containing this seat
        • scheduleId (String): ID of the schedule for this seatmap
        • rowLabel (String, optional): Human-readable row label (e.g., "1", "A")
        • label (String, optional): Seat label/number
        • sectionName (String, optional): Human-readable section name

Usage:

channel.on('sync:join', (msg) => {
  console.log('Received initial seat state:', msg.seats);
  // Block all seats that are already selected
  msg.seats.forEach((seat) => {
    blockSeat(seat.seat_id);
  });
});

2. seat:selected

Broadcast when any client selects a seat.

Payload:

{
  "selected_seat": {
    "leg_from": 0,
    "leg_to": 1,
    "seat_id": "section-5c13c12d062bb938026c3161-row-1-seat-0",
    "seat": {
      "row": 1,
      "col": 0,
      "sectionId": "5c13c12d062bb938026c3161",
      "scheduleId": "5c90f854d99e27e20d000001"
    }
  },
  "seats": [
    // Array of all currently selected seats
  ]
}

Field Descriptions:

  • selected_seat (Object, required): The seat that was just selected
    • leg_from (Number, required): Leg index for the departing station (0-based)
    • leg_to (Number, required): Leg index for the arrival station (0-based)
    • seat_id (String, required): Unique seat identifier in format section-{sectionId}-row-{row}-seat-{col}
    • seat (Object, optional): Additional seat information object that may contain:
      • row (Number): Row number in the seatmap
      • col (Number): Column number in the seatmap
      • sectionId (String): ID of the section containing this seat
      • scheduleId (String): ID of the schedule for this seatmap
      • rowLabel (String, optional): Human-readable row label (e.g., "1", "A")
      • label (String, optional): Seat label/number
      • sectionName (String, optional): Human-readable section name
  • seats (Array, required): Complete array of all currently selected seats for the seatmap. Each seat object follows the same structure as selected_seat.

Usage:

channel.on('seat:selected', (msg) => {
  const selectedSeat = msg.selected_seat;
  console.log('Seat selected:', selectedSeat.seat_id);
  // Block the seat in your UI
  blockSeat(selectedSeat.seat_id);
});

3. seat:unselected

Broadcast when any client unselects a seat.

Payload:

{
  "unselected_seat": {
    "leg_from": 0,
    "leg_to": 1,
    "seat_id": "section-5c13c12d062bb938026c3161-row-1-seat-0",
    "seat": {
      "row": 1,
      "col": 0,
      "sectionId": "5c13c12d062bb938026c3161",
      "scheduleId": "5c90f854d99e27e20d000001"
    }
  },
  "seats": [
    // Array of remaining selected seats
  ]
}

Field Descriptions:

  • unselected_seat (Object, required): The seat that was just unselected
    • leg_from (Number, required): Leg index for the departing station (0-based)
    • leg_to (Number, required): Leg index for the arrival station (0-based)
    • seat_id (String, required): Unique seat identifier in format section-{sectionId}-row-{row}-seat-{col}
    • seat (Object, optional): Additional seat information object that may contain:
      • row (Number): Row number in the seatmap
      • col (Number): Column number in the seatmap
      • sectionId (String): ID of the section containing this seat
      • scheduleId (String): ID of the schedule for this seatmap
      • rowLabel (String, optional): Human-readable row label (e.g., "1", "A")
      • label (String, optional): Seat label/number
      • sectionName (String, optional): Human-readable section name
  • seats (Array, required): Complete array of all remaining selected seats for the seatmap (after the unselection). Each seat object follows the same structure as unselected_seat.

Usage:

channel.on('seat:unselected', (msg) => {
  const unselectedSeat = msg.unselected_seat;
  console.log('Seat unselected:', unselectedSeat.seat_id);
  // Unblock the seat in your UI
  unblockSeat(unselectedSeat.seat_id);
});

4. sync:seats

Broadcast periodically (every second) when seats expire. This event is sent only if there are expired seats.

Payload:

{
  "expired": [
    {
      "leg_from": 0,
      "leg_to": 1,
      "seat_id": "section-5c13c12d062bb938026c3161-row-1-seat-0",
      "seat": {
        "row": 1,
        "col": 0,
        "sectionId": "5c13c12d062bb938026c3161",
        "scheduleId": "5c90f854d99e27e20d000001"
      }
    }
  ],
  "seats": [
    // Array of all currently selected (non-expired) seats
  ]
}

Field Descriptions:

  • expired (Array, required): Array of seat objects that have expired (TTL reached). This array will be empty if no seats expired. Each expired seat object contains:
    • leg_from (Number, required): Leg index for the departing station (0-based)
    • leg_to (Number, required): Leg index for the arrival station (0-based)
    • seat_id (String, required): Unique seat identifier in format section-{sectionId}-row-{row}-seat-{col}
    • seat (Object, optional): Additional seat information object that may contain:
      • row (Number): Row number in the seatmap
      • col (Number): Column number in the seatmap
      • sectionId (String): ID of the section containing this seat
      • scheduleId (String): ID of the schedule for this seatmap
      • rowLabel (String, optional): Human-readable row label (e.g., "1", "A")
      • label (String, optional): Seat label/number
      • sectionName (String, optional): Human-readable section name
  • seats (Array, required): Complete array of all currently selected (non-expired) seats for the seatmap. This represents the current state after removing expired seats. Each seat object follows the same structure as items in the expired array.

Note: This event is only broadcast when there are expired seats. If no seats expired, the event is not sent.

Usage:

channel.on('sync:seats', (msg) => {
  console.log('Expired seats:', msg.expired);
  // Unblock expired seats
  msg.expired.forEach((expired) => {
    unblockSeat(expired.seat_id);
  });
});

5. seat:blocked

Broadcast when a seat is blocked via the API (not through websocket). This is used for administrative blocking.

Payload:

{
  "blocked_seat": {
    "leg_from": 0,
    "leg_to": 1,
    "seat_id": "section-5c13c12d062bb938026c3161-row-1-seat-0",
    "seat": {
      "row": 1,
      "col": 0,
      "sectionId": "5c13c12d062bb938026c3161",
      "scheduleId": "5c90f854d99e27e20d000001"
    }
  },
  "seats": [
    // Array of all currently selected/blocked seats
  ]
}

Field Descriptions:

  • blocked_seat (Object, required): The seat that was just blocked via the API
    • leg_from (Number, required): Leg index for the departing station (0-based)
    • leg_to (Number, required): Leg index for the arrival station (0-based)
    • seat_id (String, required): Unique seat identifier in format section-{sectionId}-row-{row}-seat-{col}
    • seat (Object, optional): Additional seat information object that may be present if provided when blocking via API. May contain:
      • row (Number): Row number in the seatmap
      • col (Number): Column number in the seatmap
      • sectionId (String): ID of the section containing this seat
      • scheduleId (String): ID of the schedule for this seatmap
      • rowLabel (String, optional): Human-readable row label (e.g., "1", "A")
      • label (String, optional): Seat label/number
      • sectionName (String, optional): Human-readable section name
  • seats (Array, required): Complete array of all currently selected/blocked seats for the seatmap. Each seat object follows the same structure as blocked_seat.

Note: This event is triggered when a seat is blocked through the REST API endpoint (POST /seatmaps/seat with op: "block" or op: "block_seat"), not through websocket events. The blocked seat will also appear in subsequent seat:selected events since blocking uses the same cache mechanism.

Usage:

channel.on('seat:blocked', (msg) => {
  const blockedSeat = msg.blocked_seat;
  console.log('Seat blocked:', blockedSeat.seat_id);
  // Block the seat in your UI
  blockSeat(blockedSeat.seat_id);
});

Outgoing Events (Client → Server)

1. seat:selected

Send when a user selects a seat in your UI.

Payload:

{
  "seat": {
    "leg_from": 0,
    "leg_to": 1,
    "seat_id": "section-5c13c12d062bb938026c3161-row-1-seat-0",
    "seat": {
      "row": 1,
      "col": 0,
      "sectionId": "5c13c12d062bb938026c3161",
      "scheduleId": "5c90f854d99e27e20d000001"
    }
  },
  "ttl_sec": 60
}

Parameters:

  • seat.leg_from (Number, required): Leg index for departing station
  • seat.leg_to (Number, required): Leg index for arrival station
  • seat.seat_id (String, required): Unique seat identifier
  • seat.seat (Object, optional): Full seat object with row, col, sectionId, scheduleId
  • ttl_sec (Number, optional): Time to live in seconds. Default: 60 seconds (configurable in API)

Usage:

function selectSeat(seatId, legFrom, legTo, ttlSec = 60) {
  const payload = {
    seat: {
      leg_from: legFrom,
      leg_to: legTo,
      seat_id: seatId,
      seat: {
        row: 1,
        col: 0,
        sectionId: "5c13c12d062bb938026c3161",
        scheduleId: "5c90f854d99e27e20d000001"
      }
    },
    ttl_sec: ttlSec
  };
  
  channel.push('seat:selected', payload);
}

2. seat:unselected

Send when a user unselects a seat in your UI.

Payload:

{
  "seat": {
    "leg_from": 0,
    "leg_to": 1,
    "seat_id": "section-5c13c12d062bb938026c3161-row-1-seat-0",
    "seat": {
      "row": 1,
      "col": 0,
      "sectionId": "5c13c12d062bb938026c3161",
      "scheduleId": "5c90f854d99e27e20d000001"
    }
  }
}

Usage:

function unselectSeat(seatId, legFrom, legTo) {
  const payload = {
    seat: {
      leg_from: legFrom,
      leg_to: legTo,
      seat_id: seatId,
      seat: {
        row: 1,
        col: 0,
        sectionId: "5c13c12d062bb938026c3161",
        scheduleId: "5c90f854d99e27e20d000001"
      }
    }
  };
  
  channel.push('seat:unselected', payload);
}

3. ping

Test connection and get a response.

Payload: Any object (optional)

Usage:

channel.push('ping', { message: 'test' })
  .receive('ok', (response) => {
    console.log('Ping response:', response);
  });

Client Library (btrz-seatmaps)

The btrz-seatmaps library provides a high-level API for managing websocket connections and seatmap rendering.

Installation

npm install btrz-seatmaps

Basic Usage

import { SeatmapSocket, SeatmapSection } from 'btrz-seatmaps';

// 1. Configure socket settings
const socketSettings = {
  socketUrl: 'wss://api.betterez.com/seatmaps/socket',
  accessTicket: 'your-access-ticket', // Obtained server-side
  idForLiveSeatmap: `${scheduleId}_${date}`, // e.g., "5c90f854d99e27e20d000001_2022-12-08"
  legFrom: 0,
  legTo: 1,
  scheduleId: '5c90f854d99e27e20d000001',
  tripId: 'trip-id-123',
  ttlSec: 60, // Optional, defaults to 60
  callbacks: {
    seatmapJoin: (seats) => {
      console.log('Initial seats:', seats);
      // Handle initial seat state
    },
    seatmapSeatSelected: (seat) => {
      console.log('Seat selected:', seat);
      // Update UI to block seat
    },
    seatmapSeatUnSelected: (seat) => {
      console.log('Seat unselected:', seat);
      // Update UI to unblock seat
    },
    seatExpired: (expiredSeats, context) => {
      console.log('Seats expired:', expiredSeats);
      // Update UI to unblock expired seats
    }
  }
};

// 2. Initialize socket connection
SeatmapSocket.listen(socketSettings);

// 3. Create seatmap section
const seatmapSection = new SeatmapSection('seatmap-container', sectionData, {
  socketEvents: {
    tripId: 'trip-id-123',
    scheduleId: '5c90f854d99e27e20d000001',
    callbacks: {
      seatClicked: (seat, context) => {
        console.log('Seat clicked:', seat);
        // Handle seat selection in your UI
        // Then push event to websocket
        SeatmapSocket.pushEvent('seat:selected', seat, seat.seatId);
      },
      seatOver: (seat, context) => {
        // Optional: Handle hover
      },
      seatOut: (seat, context) => {
        // Optional: Handle mouse out
      }
    }
  }
});

// 4. Render seatmap
seatmapSection.draw();

SeatmapSocket API

SeatmapSocket.listen(settings)

Initializes the websocket connection and joins the channel.

Settings Object:

  • socketUrl (String, required): Websocket URL
  • accessTicket (String, required): Access ticket from API
  • idForLiveSeatmap (String, required): Seatmap ID (${scheduleId}_${date})
  • legFrom (Number, required): Leg index for departing station
  • legTo (Number, required): Leg index for arrival station
  • scheduleId (String, required): Schedule ID
  • tripId (String, required): Trip ID (used for channel management)
  • ttlSec (Number, optional): TTL for seat selections (default: 60)
  • keepChannelsConnected (Boolean, optional): Keep channels open when trip changes
  • callbacks (Object, required):
    • seatmapJoin (Function): Called on sync:join event
    • seatmapSeatSelected (Function): Called on seat:selected event
    • seatmapSeatUnSelected (Function): Called on seat:unselected event
    • seatExpired (Function): Called on sync:seats event with expired seats

Channel Management:

  • The library manages multiple channels in a Map
  • If tripId changes, existing channels are closed (unless keepChannelsConnected is true)
  • Reusing the same idForLiveSeatmap will reuse the existing channel

SeatmapSocket.pushEvent(name, seat, seatId)

Pushes an event to the websocket channel.

Parameters:

  • name (String): Event name ('seat:selected' or 'seat:unselected')
  • seat (Object): Seat object with row, col, sectionId, etc.
  • seatId (String): Unique seat identifier

Example:

// Select a seat
SeatmapSocket.pushEvent('seat:selected', {
  row: 1,
  col: 0,
  sectionId: '5c13c12d062bb938026c3161',
  scheduleId: '5c90f854d99e27e20d000001'
}, 'section-5c13c12d062bb938026c3161-row-1-seat-0');

// Unselect a seat
SeatmapSocket.pushEvent('seat:unselected', seat, seatId);

SeatmapSection API

The SeatmapSection class handles rendering and interaction with the seatmap UI. It provides methods for managing seat states, handling user interactions, and integrating with websockets for real-time updates.

Constructor

const seatmap = new SeatmapSection(containerId, section, settings);

Parameters:

  • containerId (String, required): The ID of the HTML container element where the seatmap will be rendered
  • section (Object, required): An object containing information about the seatmap section (from the seatmap API response)
  • settings (Object, optional): Additional settings for the seatmap configuration

Example:

const seatmap = new SeatmapSection('seatmap-container', sectionData, {
  fees: seatFees,
  seatClasses: seatClasses,
  labels: labels,
  socketEvents: socketEvents,
  events: customEvents
});

seatmap.draw(); // Call draw() to render the seatmap

Settings Object

The settings parameter accepts the following properties:

settings.fees

Array of seat fees associated with the seats in this section. Each fee object should have:

  • _id (String, required): Seat fee ID
  • name (String, required): Fee name
  • bgcolor (String, required): Background color for the fee indicator
  • type (String, optional): Fee type - "$" for fixed amount, "%" for percentage
  • value (String/Number, optional): Fee value

Example:

settings.fees = [
  {
    "_id": "fee-id-123",
    "name": "Window Seat",
    "bgcolor": "#d4eaf0",
    "type": "$",
    "value": "5.00"
  }
];
settings.seatClasses

Array of seat classes associated with the seats in this section. Each class object should have:

  • _id (String, required): Seat class ID
  • name (String, required): Class name
  • bgcolor (String, required): Background color for the class indicator
  • value (String, optional): Display value

Example:

settings.seatClasses = [
  {
    "_id": "class-id-123",
    "name": "Premium",
    "bgcolor": "#34def4",
    "value": "Premium Class"
  }
];
settings.labels

Object containing lexicon values for internationalization. Required properties:

settings.labels = {
  "section": "Section",
  "row": "Row",
  "seat": "Seat",
  "status": "Status",
  "seatClass": "Seat class",
  "fee": "Fee",
  "female": "Female",
  "suggested": "Suggested",
  "accessible": "Accessible"
};
settings.socketEvents

Object that defines websocket communication settings for real-time seatmap updates.

Properties:

  • scheduleId (String, required): The schedule ID
  • tripId (String, required): The trip ID
  • socketUrl (String, required): The websocket URL
  • accessTicket (String, required): Access ticket for authentication
  • idForLiveSeatmap (String, required): Live seatmap ID (format: ${scheduleId}_${date})
  • legFrom (Number, required): Leg index for departing station
  • legTo (Number, required): Leg index for arrival station
  • ttlSec (Number, optional): Seat TTL in seconds (default: 60)
  • callbacks (Object, required): Callback functions for socket events

Socket Event Callbacks:

  • seatClicked (Function): Called when a user clicks or presses Enter on a seat

    callbacks.seatClicked = (seat, context) => {
      // seat: { row, col, sectionId, rowLabel, label, status, ... }
      // context: { tripId, scheduleId }
      // Handle seat selection/unselection
    };
    
  • seatOver (Function): Called on mouseover event over a seat

    callbacks.seatOver = (seat, context) => {
      // Handle hover effects
    };
    
  • seatOut (Function): Called on mouseout event from a seat

    callbacks.seatOut = (seat, context) => {
      // Handle hover end
    };
    
  • seatExpired (Function): Called when seats expire (from sync:seats event)

    callbacks.seatExpired = (expiredSeats, context) => {
      // expiredSeats: Array of expired seat objects
      // context: { scheduleId }
      expiredSeats.forEach(seat => {
        SeatmapSection.changeSeatStatus(seat, "available");
        SeatmapSection.changeSeatDataProp(seat, "keynav", "true");
      });
    };
    
  • seatmapJoin (Function): Called when joining the channel (from sync:join event)

    callbacks.seatmapJoin = (seats) => {
      // seats: Array of currently selected seats
      seats.forEach(seat => {
        if (seat.seat && seat.seat.sectionId === currentSectionId) {
          SeatmapSection.changeSeatStatus(seat.seat, "blocked");
        }
      });
    };
    
  • seatmapSeatSelected (Function): Called when another user selects a seat (from seat:selected event)

    callbacks.seatmapSeatSelected = (seat) => {
      // seat: Selected seat object
      SeatmapSection.changeSeatStatus(seat, "blocked");
    };
    
  • seatmapSeatUnSelected (Function): Called when another user unselects a seat (from seat:unselected event)

    callbacks.seatmapSeatUnSelected = (seat) => {
      // seat: Unselected seat object
      SeatmapSection.changeSeatStatus(seat, "available");
      SeatmapSection.changeSeatDataProp(seat, "keynav", "true");
    };
    
settings.events

Array of custom event handlers for seatmap elements. Each event object should have:

  • elementType (String, optional): Type of element ("seat" or facility type). Required if elementStatus is not provided
  • elementStatus (Array, optional): Status array (["available", "blocked", etc.]). Required if elementType is not provided
  • type (String, required): Event type ("click", "blur", "focus", "mouseover", "mouseout", etc.)
  • cb (Function, required): Callback function

Example:

settings.events = [
  {
    elementType: "seat",
    elementStatus: ["available"],
    type: "click",
    cb: function(evt, e, elem) {
      // Handle seat click
      console.log('Seat clicked:', elem);
    }
  }
];

Instance Methods

draw()

Renders the seatmap section in the container.

seatmap.draw();
clearFocus()

Removes focus from any focused element within the seatmap container.

seatmap.clearFocus();
clearSelection()

Removes selection from all selected elements inside the seatmap container.

seatmap.clearSelection();
focus()

Focuses on the seatmap container and the first available seat within it.

seatmap.focus();
focusElement(elem)

Focuses on a specific element within the seatmap.

seatmap.focusElement(seatElement);

Parameters:

  • elem (Object): Seat element object with row, col, and sectionId properties
focusOnNextSelected(previousSeat)

Focuses on the next selected seat in the seatmap. Requires the previous selected seat as a parameter.

seatmap.focusOnNextSelected(previousSeat);

Parameters:

  • previousSeat (Object): Previously selected seat object
getCapacity()

Calculates the current capacity of the seatmap.

const capacity = seatmap.getCapacity();

Returns: Number representing the seatmap capacity

onSeatClicked(evt, e, seat)

Internal method that handles seat click events. Checks if the seat is blocked and sends data via socket events if configured.

seatmap.onSeatClicked(event, element, seat);
selectElement(elem)

Selects an element within the seatmap.

seatmap.selectElement(seatElement);

Parameters:

  • elem (Object): Seat element object with row, col, and sectionId properties

Static Methods

The SeatmapSection class also provides static methods that can be called without an instance:

SeatmapSection.changeSeatDataProp(seat, prop, value)

Changes a data property of a seat element.

SeatmapSection.changeSeatDataProp(seat, "selected", "true");
SeatmapSection.changeSeatDataProp(seat, "keynav", "true");

Parameters:

  • seat (Object): Seat object with row, col, and sectionId properties
  • prop (String): Property name to change
  • value (String): New value for the property

Common Properties:

  • "selected": "true" or "false" - Marks seat as selected
  • "keynav": "true" or "false" - Enables/disables keyboard navigation
SeatmapSection.changeSeatStatus(seat, status)

Changes the status of a seat element.

SeatmapSection.changeSeatStatus(seat, "blocked");
SeatmapSection.changeSeatStatus(seat, "available");
SeatmapSection.changeSeatStatus(seat, "reserved");

Parameters:

  • seat (Object): Seat object with row, col, and sectionId properties
  • status (String): New status - "available", "blocked", "reserved", "taken", or "unavailable"

Status Values:

  • "available": Seat is available for selection
  • "blocked": Seat is blocked (selected by another user or administratively)
  • "reserved": Seat is reserved/booked
  • "taken": Seat is taken (same as reserved)
  • "unavailable": Seat is unavailable
SeatmapSection.getSeatId(seat)

Generates a unique seat identifier from a seat object.

const seatId = SeatmapSection.getSeatId(seat);
// Returns: "section-{sectionId}-row-{row}-seat-{col}"

Parameters:

  • seat (Object): Seat object with sectionId, row, and col properties

Returns: String in format section-{sectionId}-row-{row}-seat-{col}

Key Features

  • Automatic websocket event handling
  • Seat selection/unselection
  • Visual feedback (blocked, selected, available states)
  • Keyboard navigation support
  • Custom styling and events
  • Support for seat fees and seat classes
  • Internationalization support via labels

API-Sales Integration

The API-Sales service integrates with seatmaps by receiving Seat Information in Bookings

When a booking is completed with seat selections, API-Sales receives the seat information in the cart/order payload.

Seat Information Structure:

{
  "passengers": [
    {
      "seats": [
        {
          "seatMapId": "5c90f854d99e27e20d000001",
          "sectionId": "5c13c12d062bb938026c3161",
          "rowLabel": "1",
          "seatNumber": "0",
          "seatId": "section-5c13c12d062bb938026c3161-row-1-seat-0",
          "sectionName": "Left",
          "scheduleId": "5c90f854d99e27e20d000001",
          "seatFeeId": "fee-id-123", // Optional
          "seatClass": "class-id-123" // Optional
        }
      ]
    }
  ]
}

Field Descriptions and Data Sources:

Each field in the seat payload must be populated from the seatmap data structure. Here's where each field comes from:

  • seatMapId (String, required): The seatmap ID

    • Source: From the seatmap response: seatmap._id
    • Example: "5c90f854d99e27e20d000001"
  • sectionId (String, required): The section ID within the seatmap

    • Source: From the seat object: seat.sectionId or section._id
    • Example: "5c13c12d062bb938026c3161"
  • rowLabel (String, required): The row label (e.g., "1", "A")

    • Source: From the seat object: seat.rowLabel (converted to string)
    • Note: If rowLabel is not directly available, you may need to derive it from the row number based on your seatmap configuration
    • Example: "1" or "A"
  • seatNumber (String, required): The seat number within the row

    • Source: From the seat object: seat.label (converted to string)
    • Note: This is the seat's label/number, not the column index
    • Example: "0", "1", "A", "B"
  • seatId (String, required): Unique seat identifier

    • Source: Constructed from seat data: section-{sectionId}-row-{row}-seat-{col}
    • Format: section-{sectionId}-row-{row}-seat-{col}
    • Example: "section-5c13c12d062bb938026c3161-row-1-seat-0"
  • sectionName (String, required): Human-readable section name

    • Source: From the section object: section.name
    • Example: "Left", "Right", "Center"
  • scheduleId (String, required): The schedule ID

    • Source: From your trip/segment data: segment.scheduleId or segment.name
    • Note: This should match the schedule ID used when fetching the seatmap
    • Example: "5c90f854d99e27e20d000001"
  • seatFeeId (String, optional): Seat fee ID (if seat fees are configured)

    • Source: From the seat object: seat.fee
    • Note: Only include if the seat has a fee configured. You should have fetched seat fee details separately to display fee information to users
    • Example: "fee-id-123"
  • seatClass (String, optional): Seat class ID (if seat classes are configured)

    • Source: From the seat object: seat.seatClass
    • Note: Only include if the seat has a class configured. You should have fetched seat class details separately to display class information to users
    • Example: "class-id-123"

Complete Example: Building Seat Payload from Seatmap Data:

function buildSeatPayload(seat, section, seatmap, scheduleId) {
  // Construct seatId
  const seatId = `section-${seat.sectionId}-row-${seat.row}-seat-${seat.col}`;
  
  // Build the payload
  const payload = {
    seatMapId: seatmap._id,
    sectionId: seat.sectionId,
    rowLabel: seat.rowLabel ? seat.rowLabel.toString() : seat.row.toString(),
    seatNumber: seat.label ? seat.label.toString() : seat.col.toString(),
    seatId: seatId,
    sectionName: section.name,
    scheduleId: scheduleId
  };
  
  // Add optional fields if present
  if (seat.fee) {
    payload.seatFeeId = seat.fee;
  }
  
  if (seat.seatClass) {
    payload.seatClass = seat.seatClass;
  }
  
  return payload;
}

// Usage when a user selects a seat
function onSeatSelected(seat, section, seatmap, scheduleId) {
  const seatPayload = buildSeatPayload(seat, section, seatmap, scheduleId);
  
  // Add to passenger's seats array
  passenger.seats.push(seatPayload);
  
  // Send to cart/order API
  addSeatToCart(passenger, seatPayload);
}

Important Notes:

  • All string fields should be converted to strings (even if they come as numbers)
  • The seatId must be constructed in the exact format shown above
  • Fee and class IDs should only be included if they exist on the seat object
  • The scheduleId must match the schedule used when fetching the seatmap data

Complete Examples

Example 1: Basic Integration with Phoenix.js

import { Socket } from 'phoenix';

class SeatmapWebsocket {
  constructor(accessTicket, seatmapId, legFrom, legTo) {
    this.socketUrl = 'wss://api.betterez.com/seatmaps/socket';
    this.accessTicket = accessTicket;
    this.seatmapId = seatmapId;
    this.legFrom = legFrom;
    this.legTo = legTo;
    this.socket = null;
    this.channel = null;
  }

  connect() {
    // Create socket
    this.socket = new Socket(this.socketUrl, {
      params: { token: this.accessTicket }
    });

    // Connect
    this.socket.connect();

    // Create channel
    this.channel = this.socket.channel(`seatmap:${this.seatmapId}`, {
      leg_from: this.legFrom,
      leg_to: this.legTo
    });

    // Join channel
    this.channel.join()
      .receive('ok', () => {
        console.log('Joined channel');
        this.setupEventHandlers();
      })
      .receive('error', (err) => {
        console.error('Join error:', err.reason);
      });
  }

  setupEventHandlers() {
    // Initial sync
    this.channel.on('sync:join', (msg) => {
      msg.seats.forEach((seat) => {
        this.blockSeat(seat.seat_id);
      });
    });

    // Seat selected
    this.channel.on('seat:selected', (msg) => {
      this.blockSeat(msg.selected_seat.seat_id);
    });

    // Seat unselected
    this.channel.on('seat:unselected', (msg) => {
      this.unblockSeat(msg.unselected_seat.seat_id);
    });

    // Seats expired
    this.channel.on('sync:seats', (msg) => {
      msg.expired.forEach((expired) => {
        this.unblockSeat(expired.seat_id);
      });
    });
  }

  selectSeat(seatId, ttlSec = 60) {
    const payload = {
      seat: {
        leg_from: this.legFrom,
        leg_to: this.legTo,
        seat_id: seatId
      },
      ttl_sec: ttlSec
    };
    this.channel.push('seat:selected', payload);
  }

  unselectSeat(seatId) {
    const payload = {
      seat: {
        leg_from: this.legFrom,
        leg_to: this.legTo,
        seat_id: seatId
      }
    };
    this.channel.push('seat:unselected', payload);
  }

  blockSeat(seatId) {
    // Your UI logic to block a seat
    console.log('Blocking seat:', seatId);
  }

  unblockSeat(seatId) {
    // Your UI logic to unblock a seat
    console.log('Unblocking seat:', seatId);
  }

  disconnect() {
    if (this.channel) {
      this.channel.leave();
    }
    if (this.socket) {
      this.socket.disconnect();
    }
  }
}

// Usage
const ws = new SeatmapWebsocket(
  'access-ticket',
  '5c90f854d99e27e20d000001_2022-12-08',
  0,
  1
);
ws.connect();

Example 2: Using btrz-seatmaps Library

import { SeatmapSocket, SeatmapSection } from 'btrz-seatmaps';

// Server-side: Get access ticket
async function initializeSeatmap(sectionData, containerId) {
  // 1. Get access ticket (server-side)
  const accessTicket = await fetch('/api/seatmap-ticket', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    }
  }).then(res => res.json()).then(data => data.ticket);

  // 2. Configure socket
  const socketSettings = {
    socketUrl: 'wss://api.betterez.com/seatmaps/socket',
    accessTicket: accessTicket,
    idForLiveSeatmap: `${scheduleId}_${date}`,
    legFrom: 0,
    legTo: 1,
    scheduleId: scheduleId,
    tripId: tripId,
    ttlSec: 60,
    callbacks: {
      seatmapJoin: (seats) => {
        console.log('Initial seats loaded:', seats.length);
      },
      seatmapSeatSelected: (seat) => {
        console.log('Seat selected by another user:', seat);
      },
      seatmapSeatUnSelected: (seat) => {
        console.log('Seat unselected by another user:', seat);
      },
      seatExpired: (expiredSeats) => {
        console.log('Seats expired:', expiredSeats.length);
      }
    }
  };

  // 3. Initialize socket
  SeatmapSocket.listen(socketSettings);

  // 4. Create seatmap section
  const seatmap = new SeatmapSection(containerId, sectionData, {
    socketEvents: {
      tripId: tripId,
      scheduleId: scheduleId,
      callbacks: {
        seatClicked: (seat, context) => {
          // Handle seat click
          if (seat.status === 'available') {
            // Select seat
            SeatmapSocket.pushEvent('seat:selected', seat, seat.seatId);
          } else if (seat.status === 'selected') {
            // Unselect seat
            SeatmapSocket.pushEvent('seat:unselected', seat, seat.seatId);
          }
        }
      }
    }
  });

  // 5. Render
  seatmap.draw();

  return seatmap;
}

Example 3: Server-Side Access Ticket Generation

// Node.js/Express example
const express = require('express');
const axios = require('axios');
const app = express();

app.post('/api/seatmap-ticket', async (req, res) => {
  try {
    const apiKey = process.env.BETTEREZ_API_KEY;
    const jwtToken = req.headers.authorization?.replace('Bearer ', '');
    
    if (!jwtToken) {
      return res.status(401).json({ error: 'JWT token required' });
    }

    const response = await axios({
      url: 'https://api.betterez.com/seatmaps/access-ticket',
      method: 'post',
      headers: {
        'X-API-KEY': apiKey,
        'Authorization': `Bearer ${jwtToken}`
      }
    });

    res.json({ ticket: response.data.ticket });
  } catch (error) {
    console.error('Error getting access ticket:', error);
    res.status(500).json({ error: 'Failed to get access ticket' });
  }
});

Troubleshooting

Connection Issues

Problem: Cannot connect to websocket from localhost Solution: Deploy to a server and whitelist the domain with Betterez support. Localhost connections are not allowed.

Problem: Connection rejected with 403 error Solution:

  • Verify the access ticket is valid and not expired (tokens expire after 30 minutes in production/sandbox)
  • Ensure the ticket was obtained server-side with valid credentials
  • Check that the JWT token used to get the ticket is valid
  • Request a new access ticket if the current one has expired

Problem: Join fails with "unauthorized" Solution:

  • Verify leg_from and leg_to are valid numbers >= 0
  • Check that the seatmap ID format is correct (${scheduleId}_${date})

Event Issues

Problem: Not receiving seat selection events Solution:

  • Verify you've joined the channel successfully
  • Check that the leg range overlaps with the seat's leg range
  • Ensure event handlers are set up after joining

Problem: Seats not expiring Solution:

  • Check the TTL value when selecting seats
  • Verify the cache cleaner is running on the server
  • Check server logs for Redis connection issues

Performance Issues

Problem: Too many websocket connections Solution:

  • Reuse socket connections when possible
  • Use the btrz-seatmaps library which manages connections efficiently
  • Close channels when not needed

Problem: High latency Solution:

  • Check network connectivity
  • Verify you're using the correct environment endpoint
  • Monitor Redis performance on the server

Problem: Connection drops after 30 minutes Solution:

  • This is expected behavior - access tokens expire after 30 minutes
  • Implement token refresh logic to request a new access ticket before expiration
  • Reconnect to the websocket with a fresh token when the connection is lost
  • Consider requesting a new token every 25 minutes to avoid connection interruptions

Additional Resources


Support

For issues or questions:

  1. Check the troubleshooting section above
  2. Review existing documentation
  3. Contact Betterez support with:
    • Your domain (for whitelisting)
    • Error messages
    • Environment (sandbox/production)
    • Seatmap ID and schedule information