# Read endpoints

Source: https://blocklive.io/docs/developers/reads

Every endpoint of the developer API, what it returns, how paging works, and how
to keep an onsite app up to date. For keys and authentication, see the
[developer API overview](https://blocklive.io/docs/developers).

All endpoints are `GET` under `https://blocklive.io/api/v1` and answer JSON.
Send your key on every request:

```http
Authorization: Bearer bl_live_<id>_<secret>
```

| Path                                         | Returns                                                                     |
| -------------------------------------------- | --------------------------------------------------------------------------- |
| `/events`                                    | The events your key can read, newest first.                                 |
| `/events/{event_id}/ticket-types`            | The event's ticket types.                                                   |
| `/events/{event_id}/attendees`               | One row per ticket holder, with only the details shared with your key.      |
| `/events/{event_id}/attendees/{attendee_id}` | One attendee.                                                               |
| `/events/{event_id}/check-ins`               | Check-ins and check-outs in the order they happened (needs venue check-in). |

## IDs

`event_id` is the event's Blocklive ID, in lower case (an upper-case ID gets
`404`). Every other ID is an opaque string made
for your key and that event: `att_…` (attendees), `typ_…` (ticket types),
`ent_…` (tickets), `pur_…` (purchases) and `qst_…` (registration questions). The same person has a different
`attendee_id` at another event and for another app. Use them only with this
API, and don't try to match them across keys or events. The one ID made to
match is `team_person_id`, if it is shared with your key: see
[matching the attendee export](#matching-the-attendee-export).

## Events

```
GET /api/v1/events
```

```json
{
    "data": [
        {
            "event_id": "6f1c…",
            "title": "Summer Opening",
            "starts_at": "2026-11-13T18:00:00.000Z",
            "ends_at": "2026-11-14T02:00:00.000Z",
            "time_zone": "Europe/London",
            "shared_fields": ["name", "checkin_state"]
        }
    ],
    "next_cursor": null,
    "complete": true,
    "as_of": "2026-11-05T12:00:00.000Z"
}
```

`shared_fields` lists the attendee details your key reads for that event.

## Ticket types

```
GET /api/v1/events/{event_id}/ticket-types
```

```json
{
    "data": [{ "ticket_type_id": "typ_…", "name": "General admission" }],
    "as_of": "2026-11-05T12:00:00.000Z",
    "shared_fields": ["ticket_entitlements", "ticket_type_names"]
}
```

`name` is there only if ticket type names are shared with your key. This
endpoint isn't paged.

## Attendees

```
GET /api/v1/events/{event_id}/attendees
```

```
GET /api/v1/events/{event_id}/attendees/{attendee_id}
```

One row per ticket holder. Every row has:

| Field         | Meaning                                                                                  |
| ------------- | ---------------------------------------------------------------------------------------- |
| `event_id`    | The event.                                                                               |
| `attendee_id` | The holder, as an opaque ID for your key.                                                |
| `eligibility` | `eligible` (holds a ticket), `pending` (a purchase is still processing) or `ineligible`. |
| `updated_at`  | When anything about this attendee last changed.                                          |

A guest order (placed without a Blocklive account) that is still waiting for its ticket, or was refused, is an attendee of its own. Once its ticket is issued, that person appears under the ticket holder's `attendee_id` instead, and the order's earlier `attendee_id` stops answering.

The rest appears only if it is shared with your key:

| Field                  | Detail                  | Value                                                                                                                                          |
| ---------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `email`                | Email address           | A string or `null`.                                                                                                                            |
| `name`                 | Full name               | A string or `null`.                                                                                                                            |
| `team_person_id`       | Team person ID          | The person's ID in the event's team, or `null`. See [matching the attendee export](#matching-the-attendee-export).                             |
| `company`              | Company                 | A string or `null`.                                                                                                                            |
| `job_title`            | Job title               | A string or `null`.                                                                                                                            |
| `organisation_type`    | Organisation type       | A string or `null`.                                                                                                                            |
| `registration_answers` | Registration answers    | Their answers to the event's other questions. See [registration answers](#registration-answers).                                               |
| `ticket_entitlements`  | Ticket types and access | Their tickets: `id`, `ticket_type_id`, `status`, `checkin_state` if venue check-in is shared, and `order_status` if purchase status is shared. |
| `ticket_type_names`    | Ticket type names       | `id` and `name` of each ticket type they hold. Needs ticket types and access.                                                                  |
| `purchase_state`       | Purchase status         | Their purchases: `id`, `state` (`confirmed`, `pending`, `rejected` or `unknown`) and `order_status`.                                           |
| `checkin_state`        | Venue check-in          | `checked_in`, `not_checked_in` or `unknown`.                                                                                                   |
| `checkin_time`         | Check-in time           | When they were checked in, or `null`. Needs venue check-in.                                                                                    |

```json
{
    "data": [
        {
            "event_id": "6f1c…",
            "attendee_id": "att_…",
            "eligibility": "eligible",
            "updated_at": "2026-11-05T11:58:00.000Z",
            "name": "Sam Lee",
            "checkin_state": "not_checked_in"
        }
    ],
    "next_cursor": "…",
    "complete": false,
    "as_of": "2026-11-05T12:00:00.000Z",
    "shared_fields": ["name", "checkin_state"]
}
```

### Job title, company and organisation type

`job_title`, `company` and `organisation_type` come only from the event's
registration questions that the organizer marked as that attendee field in
the registration form (**Attendee field**: Job title, Company or Organisation
type). Nothing is read from a question's wording. A question the organizer
left as their own question is in `registration_answers`; marking it later
keeps the answers already given, and they fill the field from then on.

Events made before marking existed had their job title and company questions
marked once by Blocklive, from their wording ("What's your job title?",
"Company name"; never a company website), so they read as before.

If an event marks two questions for one field, the first in the form's order
fills it.

Each detail is the attendee's own answer: first the answers on their own
tickets, then the answers given for their order while it waits for a ticket
(the ones with the buyer's email, when the order holds tickets for others),
then the ones they gave from their account on no ticket and for no other
ticket, the newest first within each. An answer someone gave on another
person's ticket belongs to that ticket's holder. An attendee with two tickets
and a different answer on each gets the newer one; `registration_answers` has
the same rule.

### Registration answers

With registration answers shared, each attendee has `registration_answers`:
their answer to each of the event's other registration questions they
answered, in the order the event asks them.

| Field         | Value                                                                                                                                                                                   |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `question_id` | The question, as an opaque ID (`qst_…`) for your key and that event. It stays the same if the organizer renames the question.                                                           |
| `key`         | A readable key: the question's title as lower-case words joined by `_`, then the first 8 characters of `question_id`. It changes if the question is renamed, so match on `question_id`. |
| `question`    | The question as the organizer wrote it.                                                                                                                                                 |
| `answer`      | The answer as text. A choice question gives the option they picked; a checkbox gives `Yes` or `No`.                                                                                     |

Only questions the event still asks are included. Questions that fill an
attendee field (job title, company, organisation type) and the email, full
name, phone number, wallet and social account questions are left out: email,
name and the marked ones have fields of their own. A question the attendee
left blank isn't listed.

```json
"registration_answers": [
    {
        "question_id": "qst_…",
        "key": "whats_your_job_title_3f9a12c4",
        "question": "What's your job title?",
        "answer": "Head of Partnerships"
    },
    {
        "question_id": "qst_…",
        "key": "do_you_want_to_receive_updates_and_newsletter_8b20e7d1",
        "question": "Do you want to receive updates and newsletter?",
        "answer": "Yes"
    }
]
```

### Statuses as the attendee export names them

An organizer's attendee export (Event → Attendees → Export) and this API
describe the same orders and tickets. The export's words are Blocklive's own
order statuses, which the API gives as `order_status`:

| Attendee export                       | API                                                                                                                                                                                                                |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `orderStatus`                         | `order_status`, on each item of `purchase_state` and of `ticket_entitlements` (needs purchase status). The same word: `processed`, `fulfilled`, `minted`, `processing`, `pending_approval`, `rejected`, and so on. |
| `orderStatus` `processed`             | `order_status: "processed"`. Its `state` is `confirmed`.                                                                                                                                                           |
| `attendeeStatus` `Tickets received`   | A ticket with `order_status: "processed"` and `checkin_state: "not_checked_in"`.                                                                                                                                   |
| `attendeeStatus` `Checked in`         | A ticket with `order_status: "processed"` and `checkin_state: "checked_in"`.                                                                                                                                       |
| `attendeeStatus` `Tickets processing` | A ticket or order whose `order_status` is `fulfilled`, `minted`, `processing`, `awaiting_processing` or another in-between status.                                                                                 |
| `attendeeStatus` `Pending approval`   | An order with `order_status: "pending_approval"` (`state: "pending"`, `eligibility: "pending"`).                                                                                                                   |
| `attendeeStatus` `Declined`           | An order with `order_status: "rejected"` (`state: "rejected"`).                                                                                                                                                    |
| A ticket row                          | An item of `ticket_entitlements`. Its `status` is always `active`: the attendee holds it now.                                                                                                                      |

`state` groups the order statuses: `confirmed` is `processed`, `fulfilled`,
`minted` or `failed_to_deliver` (the ticket is theirs, even if an email
failed); `pending` is still on its way or waiting for approval; `rejected` is
declined. The export counts only `processed` as Tickets received, so use
`order_status` to match it exactly.

### Matching the attendee export

`team_person_id` is a person's ID in the event's team: `tpi_` and 22 letters,
digits, `-` and `_`. It is the same for that person at every event of the
team, in this API and in the team's exports, where it is the last column,
`team_person_id`, of the attendee export (Event → Attendees → Export) and of
the order export (Event → Orders → Export, the buyer's). Join the API's rows
and an export's on it.

-   Another team has a different ID for the same person, so match what you
    read with exports from the team the event belongs to. It doesn't matter
    whether your key is the team's or its organization's.
-   An event moved to another team has that team's IDs from then on.
-   It is `null` for a ticket held by a wallet without a Blocklive account and
    for an order placed without one.
-   An export's ID is always the one for its row's `profileId` (or
    `userProfileId`), so files keyed by those can move to it: export once,
    and the file has both side by side. A ticket in a wallet that was unlinked
    from an account can still be listed under that account in the export,
    with its ID, while this API lists it under the wallet, without one.
-   If an ID can't be worked out at the time, the answer leaves the field out
    (read again later), and an export goes out with the column empty.

### Only confirmed or processed attendees

```
GET /api/v1/events/{event_id}/attendees?purchase_state=confirmed
GET /api/v1/events/{event_id}/attendees?order_status=processed
```

`purchase_state` keeps only the attendees with at least one purchase in one of
the given states, as the `purchase_state` field reports them: `confirmed`,
`pending`, `rejected` or `unknown`, or several separated by commas
(`confirmed,pending`).

`order_status` keeps only the attendees with at least one order in one of the
given order statuses, the attendee export's `orderStatus` words, as
`order_status` reports them, or several separated by commas
(`processed,fulfilled`). `order_status=processed` is exactly the export's
processed orders; `purchase_state=confirmed` also takes orders the export still
shows as processing (see the table above).

Both need purchase status shared with your key; without it, or with any other
value, the answer is `400 invalid_request`. Given together, an attendee needs
an order that matches both. Attendees with no order (such as tickets an
organizer sent) aren't listed when either is set. An attendee who also has an
order in another state is listed, with all their purchases in `purchase_state`.

Pass the same filters with each `cursor`. A cursor from a list with other
filters, or none, answers `410 resync_required`.

## Check-ins

```
GET /api/v1/events/{event_id}/check-ins?cursor=<last next_cursor>
```

```json
{
    "data": [
        {
            "attendee_id": "att_…",
            "entitlement_id": "ent_…",
            "checkin_state": "checked_in",
            "at": "2026-11-13T18:00:00.000Z"
        }
    ],
    "next_cursor": "…",
    "complete": true,
    "as_of": "2026-11-13T18:00:05.000Z"
}
```

Needs venue check-in shared with your key. Without it, this endpoint answers
`404 not_found`, the same as an event your key can't read.

-   `checkin_state` is `checked_in` or `checked_out`, one row per scan or change
    at the door, oldest first. A ticket can appear more than once (scanned
    again, or out and back in): apply rows in order, and the last one for a
    ticket is its state.
-   A scan shows up about 5 seconds after it happens.
-   `entitlement_id` (the ticket) is there only if ticket types and access are
    shared. `attendee_id` is the ticket's holder now (it matches the attendees
    endpoint), or `null` for a ticket that no longer exists.
-   Every answer has a `next_cursor`, even an empty one. Keep the last one and
    send it on the next poll: you get only what happened since.

The attendees endpoint gives each attendee's current `checkin_state` and
`checkin_time`, if shared. It is the source of truth: use it to correct your
copy when you resync.

## Pages

`/events`, `/attendees` and `/check-ins` return up to `limit` rows (1 to 100,
default 100) and a `next_cursor`. Pass it back as `?cursor=` for the next page.
`complete: true` means there is nothing after this page. `limit` and `cursor`
are the only query parameters, except `purchase_state` and `order_status` on
`/attendees` (see
[only confirmed or processed attendees](#only-confirmed-or-processed-attendees)).

A cursor is sealed: it can't be read or changed. Pass it back exactly as you got
it.

A page cursor can be used for 15 minutes; a check-in cursor for 24 hours, so a
door app restarted during doors carries on where it was.

A `410 resync_required` means the cursor can't be used any more: it is too old,
or the organizer changed what your key may read. Start again without a cursor.
See [limits and errors](https://blocklive.io/docs/developers/limits-and-errors).

## Keeping an onsite app up to date

1. **At start-up, and every 10 minutes:** read every page of
   `/events/{event_id}/attendees` for the guest list.
2. **Every 5 seconds during doors:** poll `/events/{event_id}/check-ins` with
   your last cursor, and apply the rows in order. An empty answer is cheap.
3. On `410`, drop the cursor and read check-ins from the start (it replays the
   night's scans, oldest first). On `429`, wait for `Retry-After`.

That is about 12 requests a minute for check-ins plus one guest-list sync every
10 minutes (one request per 100 attendees), well under the limit. Don't run
more than one poller per key.

Check-in itself stays in Blocklive's door app. This API reads it; it doesn't
change it.
