---
title: "Implementing the Tickets Adapter Endpoint"
canonical_url: "https://apidocs.sportradar.com/resources/widgets/docs/tutorials/examples/adapter-tickets-endpoint-example"
markdown_url: "https://apidocs.sportradar.com/resources/widgets/docs/tutorials/examples/adapter-tickets-endpoint-example.md"
last_updated: "2026-09-14T11:56:21Z"
---

# Implementing the Tickets Adapter Endpoint

## Intended Audience

- Developers implementing a custom adapter for Virtual Stadium or Central Hub
- Integrators managing adapter communication between widgets and backend systems

## Goals

By completing this tutorial, you will:

- Understand the two implementation methods for the `tickets` adapter endpoint
- Implement a `tickets` request handler that returns [`TicketResponseV2`](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#ticketresponsev2) via `callback`
- Choose between delivering all tickets immediately (Option A) or paginating with `ticketsFetchMore` (Option B)
- Handle the `ticketsFetchMore` onAction when using pagination

## Prerequisites

Before implementing the `tickets` endpoint, ensure you have:

- A backend API or database that stores user bet / ticket data
- Familiarity with callback-based request handlers
- Access to the [Adapter Types documentation](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md) for endpoint specifications
- Bet sharing enabled in your integration (Virtual Stadium chat or Central Hub)

> **Info**
>
> The `tickets` endpoint is one of several adapter endpoints. Implement additional endpoints (`event`, `eventMarkets`, `betSlipSelection`, etc.) as needed for your widget features.

## Verified against

This tutorial reflects behaviour verified in the `widgets` repo (MR 15860):

| Source                                                  | What it proves                                                                                      |
| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `src/types/adapter/v2Requests.ts`                       | `TicketsRequest`: `endCustomerId`, optional `events`, `channelId` only — no `operatorId`, no `page` |
| `src/models/adapter/adapterProxy.ts`                    | Widget stack calls your `tickets(args, callback)` once; **you** call `callback` to deliver data     |
| `src/buildingblocks/virtualStadium/.../userBetSlips.ts` | VS single `tickets()` invocation; pagination via `ticketsFetchMore`                                 |
| `src/buildingblocks/centralHub/.../userBetSlips.ts`     | CH uses the same `tickets()` + `ticketsFetchMore` contract                                          |
| `userBetSlips.spec.ts`                                  | Option A single emission and Option B pagination tested                                             |

***

## Overview

The `tickets` endpoint retrieves a user's placed bets and returns them as v2 [`Ticket`](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#ticket) objects inside a [`TicketResponseV2`](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#ticketresponsev2). This powers bet-sharing UIs — for example the Virtual Stadium chat bet-share picker and Central Hub share-betslip screens.

There are **two implementation methods**. Both use the same `tickets(args, callback)` signature and may return an unsubscribe function for cleanup when the picker closes, but they differ in how data is delivered:

|                       | **Option A — Non-paginated**                                           | **Option B — Paginated**                                                                                            |
| --------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **When to use**       | Small ticket lists; backend returns everything at once                 | Large ticket histories; backend loads in batches                                                                    |
| **First callback**    | `{ tickets: Ticket[] }` — complete list                                | `{ tickets, pageSize, hasMore, nextCursor }` — first batch                                                          |
| **Further callbacks** | Usually none — single `{ tickets }` emission is tested and recommended | **You** call the stored `callback` with `{ newTickets, hasMore?, nextCursor? }` after `onAction` `ticketsFetchMore` |
| **Pagination**        | N/A                                                                    | Widget fires `onAction` `ticketsFetchMore` when the next page is needed                                             |

> **Who calls callback?**
>
> The widget stack invokes your `tickets(args, callback)` once. **You** (your adapter code, or your embed-page handler after `onAction` `ticketsFetchMore`) call `callback(undefined, data)` to push data to the widget. The widget never calls `callback`.

> **Pagination does not call tickets() again**
>
> Page 2+ is not fetched by invoking `tickets(args, callback)` again. Store the `callback` from the initial `tickets()` call and call it with `{ newTickets, ... }` after your embed page handles `onAction` `ticketsFetchMore`.

See the [Tickets Function reference](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#tickets-function) for flow diagrams and field details.

***

## Track A — Non-paginated (recommended for getting started)

Return the user's full open-ticket list in a single callback. The widget renders everything immediately with no pagination.

```mermaid
sequenceDiagram
  participant Widget as Widget
  participant Adapter as ClientAdapter

  Widget->>Adapter: tickets(args, callback)
  Adapter-->>Widget: callback(undefined, { tickets: [all tickets] })
  Note over Widget: Renders full list. No pagination.

  Widget->>Adapter: unsubscribe()
```

### Step 1: Register your adapter

Register your adapter with the SIR global function before adding any widgets. Ensure only one adapter is registered per page load.

```javascript
const adapter = {
  endpoints: {
    // tickets endpoint added in the next steps
  }
};

SIR('registerAdapter', adapter);
```

### Step 2: Implement the tickets endpoint skeleton

Add the `tickets` handler. Store nothing yet — just wire the callback pattern.

```javascript
adapter.endpoints.tickets = (args, callback) => {
  // args.endCustomerId — the logged-in user's ID
  // args.events — optional filter by match events on the current channel
  // args.channelId — optional Virtual Stadium channel ID

  return () => {
    // Cleanup: cancel any in-flight fetch when the picker closes
  };
};
```

### Step 3: Fetch all tickets from your backend

Retrieve the user's complete open-ticket list. Apply optional `events` filtering if provided.

```javascript
adapter.endpoints.tickets = (args, callback) => {
  fetchAllUserTickets(args.endCustomerId, args.events, (error, allTickets) => {
    if (error) {
      return callback(error);
    }
    // Continue to Step 4
  });

  return () => {};
};
```

### Step 4: Map to Ticket[] and call callback once

Transform your backend data into v2 [`Ticket`](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#ticket) objects and emit the full list.

```javascript
adapter.endpoints.tickets = (args, callback) => {
  fetchAllUserTickets(args.endCustomerId, args.events, (error, allTickets) => {
    if (error) {
      return callback(error);
    }

    const tickets = allTickets.map(mapBackendTicketToV2Ticket);

    callback(undefined, { tickets });
  });

  return () => {};
};
```

Each ticket must include `ticketId`, `version: "2.0"`, and at least one [`Bet`](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#bet) with selections, odds, and stake. Provide [`TicketSelectionContext`](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#ticketselectioncontext) on each selection so tickets remain readable after market data ages out.

### Step 5: Verify in the widget

Open the bet-share picker (Virtual Stadium chat or Central Hub). All tickets should appear immediately without pagination.

### Complete example — Track A

```javascript
function mapBackendTicketToV2Ticket(backendTicket) {
  return {
    ticketId: backendTicket.id,
    version: "2.0",
    bets: backendTicket.bets.map((bet) => ({
      betId: bet.id,
      selections: bet.selections.map((sel) => ({
        type: "uf",
        eventId: sel.eventId,
        marketId: sel.marketId,
        outcomeId: sel.outcomeId,
        specifiers: sel.specifiers,
        odds: { type: "decimal", value: String(sel.odds) },
        context: {
          eventName: sel.eventName,
          marketName: sel.marketName,
          outcomeName: sel.outcomeName,
          tournament: sel.tournament,
          sportName: sel.sportName,
          isLive: sel.isLive,
          eventStartTime: sel.eventStartTime
        }
      })),
      odds: { type: "decimal", value: String(bet.odds) },
      stake: [{ type: "cash", currency: bet.currency, amount: String(bet.stake), mode: "total" }],
      payout: bet.payout
        ? [{ type: "cash", currency: bet.currency, amount: String(bet.payout) }]
        : undefined
    }))
  };
}

const adapter = {
  endpoints: {
    tickets: (args, callback) => {
      fetchAllUserTickets(args.endCustomerId, args.events, (error, allTickets) => {
        if (error) {
          return callback(error);
        }
        callback(undefined, {
          tickets: allTickets.map(mapBackendTicketToV2Ticket)
        });
      });

      return () => {};
    }
  }
};

SIR('registerAdapter', adapter);
```

***

## Track B — Paginated (large ticket histories)

Return the first batch with `pageSize`, `hasMore`, and `nextCursor`. When the widget needs the next page, it fires `onAction` `ticketsFetchMore`; **you** load the next batch and call the stored `callback` with `{ newTickets, ... }`.

```mermaid
sequenceDiagram
  participant Widget as Widget
  participant Page as EmbedPage_onAction
  participant Adapter as ClientAdapter

  Widget->>Adapter: tickets(args, callback)
  Adapter-->>Widget: callback(undefined, { tickets: [page1], pageSize: 20, hasMore: true, nextCursor: "c1" })
  Note over Widget: Detects paginated mode.

  Widget->>Page: onAction ticketsFetchMore { cursor: "c1" }
  Page->>Adapter: loadMoreTickets("c1")
  Adapter-->>Widget: callback(undefined, { newTickets: [page2], hasMore: false })
  Note over Widget: Appends page 2. End of list.

  Widget->>Adapter: unsubscribe()
```

### Step 1: Implement the tickets endpoint skeleton

Same as Track A Step 1–2, but store the callback reference so the embed page can push subsequent pages.

```javascript
let ticketsCallback = null;
let currentCustomerId = null;

const adapter = {
  endpoints: {
    tickets: (args, callback) => {
      ticketsCallback = callback;
      currentCustomerId = args.endCustomerId;
      // Continue in Step 2
      return () => {
        ticketsCallback = null;
        currentCustomerId = null;
      };
    }
  }
};
```

### Step 2: Return the first page with pagination metadata

Signal paginated mode by including `pageSize` and `hasMore` on the first callback.

```javascript
tickets: (args, callback) => {
  ticketsCallback = callback;
  currentCustomerId = args.endCustomerId;

  fetchTicketPage(args.endCustomerId, null, args.events, (error, page) => {
    if (error) {
      return callback(error);
    }
    callback(undefined, {
      tickets: page.items.map(mapBackendTicketToV2Ticket),
      pageSize: page.pageSize,
      hasMore: page.hasMore,
      nextCursor: page.nextCursor
    });
  });

  return () => {
    ticketsCallback = null;
    currentCustomerId = null;
  };
}
```

### Step 3: Expose a loadMoreTickets function on the embed page

Create a function the `onAction` handler can call. It fetches the next page and pushes `newTickets` on the stored callback.

```javascript
function loadMoreTickets(cursor) {
  if (!ticketsCallback || !currentCustomerId) {
    return;
  }
  fetchTicketPage(currentCustomerId, cursor, null, (error, page) => {
    if (error) {
      return ticketsCallback(error);
    }
    ticketsCallback(undefined, {
      newTickets: page.items.map(mapBackendTicketToV2Ticket),
      hasMore: page.hasMore,
      nextCursor: page.nextCursor
    });
  });
}
```

### Step 4: Handle onAction ticketsFetchMore

Wire the widget's `ticketsFetchMore` onAction to your `loadMoreTickets` function.

```javascript
SIR('addWidget', '#sr-vs-widget', 'virtualStadium', {
  onAction: function(action) {
    if (action.type === 'ticketsFetchMore') {
      loadMoreTickets(action.data?.cursor);
    }
  }
});
```

### Step 5: Set hasMore false on the final page

On the last batch, return `hasMore: false` (and omit `nextCursor`). The widget stops requesting more pages.

```javascript
ticketsCallback(undefined, {
  newTickets: lastPageItems.map(mapBackendTicketToV2Ticket),
  hasMore: false
});
```

### Step 6: Verify pagination in the widget

Open the bet-share picker. Confirm:

1. The first page renders immediately.
2. When the widget requests the next page (`ticketsFetchMore`), the next batch loads and appends.
3. Loading stops when `hasMore: false` is returned.

### Complete example — Track B

```html
<script>
  let ticketsCallback = null;
  let currentCustomerId = null;

  function mapBackendTicketToV2Ticket(backendTicket) {
    return {
      ticketId: backendTicket.id,
      version: "2.0",
      bets: [{
        betId: backendTicket.betId,
        selections: [{
          type: "uf",
          eventId: backendTicket.eventId,
          marketId: backendTicket.marketId,
          outcomeId: backendTicket.outcomeId,
          odds: { type: "decimal", value: String(backendTicket.odds) },
          context: {
            eventName: backendTicket.eventName,
            marketName: backendTicket.marketName,
            outcomeName: backendTicket.outcomeName
          }
        }],
        odds: { type: "decimal", value: String(backendTicket.odds) },
        stake: [{ type: "cash", currency: "EUR", amount: String(backendTicket.stake), mode: "total" }]
      }]
    };
  }

  function loadMoreTickets(cursor) {
    if (!ticketsCallback || !currentCustomerId) {
      return;
    }
    fetch(`/api/tickets?customerId=${currentCustomerId}&cursor=${cursor || ''}`)
      .then((res) => res.json())
      .then((page) => {
        ticketsCallback(undefined, {
          newTickets: page.items.map(mapBackendTicketToV2Ticket),
          hasMore: page.hasMore,
          nextCursor: page.nextCursor
        });
      })
      .catch((err) => ticketsCallback(err));
  }

  const adapter = {
    endpoints: {
      tickets: (args, callback) => {
        ticketsCallback = callback;
        currentCustomerId = args.endCustomerId;

        fetch(`/api/tickets?customerId=${args.endCustomerId}`)
          .then((res) => res.json())
          .then((page) => {
            callback(undefined, {
              tickets: page.items.map(mapBackendTicketToV2Ticket),
              pageSize: page.pageSize,
              hasMore: page.hasMore,
              nextCursor: page.nextCursor
            });
          })
          .catch((err) => callback(err));

        return () => {
          ticketsCallback = null;
          currentCustomerId = null;
        };
      }
    }
  };

  SIR('registerAdapter', adapter);

  SIR('addWidget', '#sr-vs-widget', 'virtualStadium', {
    onAction: function(action) {
      if (action.type === 'ticketsFetchMore') {
        loadMoreTickets(action.data?.cursor);
      }
    }
  });
</script>
```

***

## Response fields (both tracks)

Deliver data on the **same** `callback` from the initial `tickets()` call — never invoke `tickets()` again for page 2+.

| Field                               | Option A                                  | Option B                         | Verified            |
| ----------------------------------- | ----------------------------------------- | -------------------------------- | ------------------- |
| `tickets`                           | Single emission with the full list        | First page only                  | Option A: yes       |
| `newTickets`                        | Not used — return everything in `tickets` | Next pagination page             | Option B in VS: yes |
| `pageSize`, `hasMore`, `nextCursor` | Not used                                  | First-page pagination signalling | Option B: yes       |

***

## Legacy format

> **Warning**
>
> [`BetShareResponse`](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#betshareresponse-deprecated) (`{ bets: BetSlip[] }`) is **deprecated**. New Virtual Stadium integrations must return [`TicketResponseV2`](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#ticketresponsev2). The legacy format remains supported only for existing V1 adapter integrations.

***

## Next Steps

- Review the [Tickets Function reference](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#tickets-function) for flow diagrams and field tables
- See [Ticket](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#ticket) and [TicketSelection](https://apidocs.sportradar.com/resources/widgets/docs/adapter/Types.md#ticketselection) for the full v2 schema and UI mapping
- Configure Virtual Stadium bet share: [Hosted adapter](https://apidocs.sportradar.com/resources/widgets/docs/virtual-stadium/web/adapter/hosted/features/betShare.md) or [Custom adapter](https://apidocs.sportradar.com/resources/widgets/docs/virtual-stadium/web/adapter/custom/features/betShare.md)
- For frontend copy-bet behaviour, see the [Copy Bet Button guide](https://apidocs.sportradar.com/resources/widgets/docs/virtual-stadium/web/widgets/virtualStadium/features/copyBetButton.md)
