# Ticket extras

Ticket extras are the optional things a ticket does beyond entry. Each one is
off until you turn it on, and an event with none of them behaves exactly as
before.

| Extra | What it does |
| --- | --- |
| **Vault** | Media a ticket unlocks — photos, video, audio, artwork, files. An item with a future `unlock_at` is a time capsule. |
| **Stub** | After check-in the ticket becomes a keepsake stub with its door number, arrival time, artwork and an audio loop. |
| **Stamps** | Stations a checked-in holder collects (bar, second stage, a sponsor booth). |
| **Polls** | Votes only checked-in tickets can cast — one per ticket. |
| **Crafting** | A customer trades checked-in tickets from your past events for a reward perk. |

"Checked in" always means the ticket was admitted through
[scanning](#api-scanning). A holder is a `ticket_id` or a `customer_id` — there
are no wallets or on-chain concepts in these responses.

## Configuration

The stub, stamp stations and crafting rule live on the event. Send
`ticket_extras` on [`POST /v1/events`](#post-v1-events) or
[`PATCH /v1/events/{id}`](#patch-v1-events-id); every key is optional.

```json
{
  "ticket_extras": {
    "stub": {
      "enabled": true,
      "image_url": "https://acme.example/img/stub.jpg",
      "audio_url": "https://acme.example/audio/loop.mp3"
    },
    "stamps": [
      { "name": "Bar", "sponsor": "Acme Brewing" },
      { "name": "Stage 2" }
    ],
    "crafting": {
      "stubs_required": 5,
      "reward_name": "Season pass",
      "reward_unlimited": true
    }
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `stub.enabled` | boolean | Turns the stub on. |
| `stub.image_url` | string | Stub artwork, shown after check-in. `https://`, at most 2048 characters. Before check-in the event image is shown. |
| `stub.audio_url` | string | Audio loop, returned only after check-in. Same URL rules. |
| `stamps` | array | Up to 20 stations, each `{ "name", "sponsor"? }` (60 characters each). The response adds an `id` per station. |
| `crafting.stubs_required` | integer | How many checked-in tickets the reward costs, `1`–`50`. |
| `crafting.reward_name` | string | Name of the reward perk, up to 80 characters. |
| `crafting.reward_description` | string | Optional, up to 300 characters. |
| `crafting.reward_unlimited` | boolean | `true` makes the reward redeemable any number of times (a pass). Default: single use. |

On `PATCH`, each of `stub`, `stamps` and `crafting` is **replaced** when you
send it, **kept** when you omit it and **removed** when you send `null`. To
keep a station across an edit, re-send it with its `id` — a station sent
without an `id` is a new station, and stamps collected at the old one no longer
count.

The event object echoes the configuration back as `ticket_extras`. The key is
absent on events that have none.

> Errors: `400 parameter_invalid` with `param` naming the field, e.g.
> `ticket_extras.stub.image_url` or `ticket_extras.crafting.stubs_required`.

## Vault

### POST /v1/events/{id}/vault

Add a vault item. **Scope:** `events:write`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | string | Yes | `photo`, `video`, `audio`, `artwork` or `file`. |
| `title` | string | Yes | Up to 120 characters. |
| `url` | string | Yes | Where the media lives. `https://`, at most 2048 characters. |
| `tier` | string | No | Only tickets in this tier can open the item. |
| `attended_only` | boolean | No | Only checked-in tickets can open it. |
| `requires_all_stamps` | boolean | No | Only tickets that collected every stamp can open it. |
| `unlock_at` | string | No | ISO 8601 time. The item stays locked until then. |

Gates combine: an item with `tier` and `attended_only` needs both.

```bash
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/vault \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "photo",
    "title": "Night one gallery",
    "url": "https://acme.example/galleries/night-one",
    "attended_only": true
  }'
```

```json
{
  "id": "vi_5b0c7a52-0c1e-4f0a-9a57-2f3c1d9e8b11",
  "kind": "photo",
  "title": "Night one gallery",
  "url": "https://acme.example/galleries/night-one",
  "tier": null,
  "attended_only": true,
  "requires_all_stamps": false,
  "unlock_at": null,
  "created_at": "2026-10-04T12:00:00.000Z"
}
```

This API takes a link, not a file: host the media yourself and pass its `url`.
To have TicketConnect store the file instead, upload it in the organizer panel
(**Add vault item** on the event page) — the item then appears here like any
other.

> The `url` is handed to a holder as-is once their ticket unlocks the item, so
> anyone they share it with can open it too. Host private media behind your own
> expiring links if that matters.

### GET /v1/events/{id}/vault

List the event's vault items, oldest first. **Scope:** `events:read`.

### DELETE /v1/events/{id}/vault/{item_id}

Remove an item. **Scope:** `events:write`. Returns `{ "id": "...", "deleted": true }`.

## GET /v1/tickets/{id}/extras

What the holder of this ticket sees. **Scope:** `tickets:read`.

```json
{
  "object": "ticket_extras",
  "ticket_id": "tkt_7d1e2f3a4b5c",
  "stub": {
    "state": "stub",
    "image_url": "https://acme.example/img/stub.jpg",
    "audio_url": "https://acme.example/audio/loop.mp3",
    "door_number": 17,
    "scanned_at": "2026-10-04T21:41:07.000Z"
  },
  "stamps": [
    { "id": "st_4c1f9a2b7d3e", "name": "Bar", "sponsor": "Acme Brewing", "collected_at": "2026-10-04T22:05:31.000Z" },
    { "id": "st_8e2a6b1c9f04", "name": "Stage 2", "sponsor": null, "collected_at": null }
  ],
  "stamps_complete": false,
  "vault": [
    { "id": "vi_5b0c…", "kind": "photo", "title": "Night one gallery", "locked": false, "lock_reason": null, "unlock_at": null, "url": "https://acme.example/galleries/night-one", "download_url": "https://acme.example/galleries/night-one" },
    { "id": "vi_9d4e…", "kind": "audio", "title": "Bonus track", "locked": true, "lock_reason": "stamps_incomplete", "unlock_at": null, "url": null, "download_url": null }
  ],
  "polls": []
}
```

| Field | Notes |
| --- | --- |
| `stub` | `null` when the event has no stub. `state` is `ticket` before check-in and `stub` after. `door_number` is the ticket's arrival order by scan time; it and `scanned_at` and `audio_url` are `null` before check-in. |
| `stamps` | Every station of the event, with `collected_at` set once this ticket collected it. |
| `vault[].lock_reason` | `tier`, `not_attended`, `stamps_incomplete` or `not_yet` (a time capsule — see `unlock_at`). A locked item never carries a `url`. |
| `vault[].download_url` | A link that saves the file instead of opening it. Differs from `url` only for images and videos uploaded through the organizer panel; for your own links it is the same address. `null` while locked. |
| `polls` | The event's polls with live results and this ticket's `my_vote` (an option index, or `null`). |

> Errors: `404 resource_missing` (not your ticket), `409 ticket_not_valid`
> (the ticket was revoked or refunded).

## POST /v1/tickets/{id}/stamps

Collect a stamp for a ticket. **Scope:** `scanning:write`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `station_id` | string | Yes | A station `id` from the event's `ticket_extras.stamps`. |

```json
{ "collected": true, "already_collected": false, "stamps_complete": true }
```

Collecting the same stamp twice is a no-op (`already_collected: true`).

> Errors: `404 unknown_station`, `409 not_attended` (the ticket is not checked
> in yet), `409 ticket_not_valid`.

## Polls

### POST /v1/events/{id}/polls

Open a poll. **Scope:** `events:write`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `question` | string | Yes | Up to 200 characters. |
| `options` | string[] | Yes | 2–6 options, up to 60 characters each. |

```json
{
  "id": "poll_1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
  "question": "Encore?",
  "options": [
    { "label": "Track A", "votes": 12 },
    { "label": "Track B", "votes": 30 }
  ],
  "total_votes": 42,
  "by_section": [
    { "section": "VIP", "votes": [2, 9] },
    { "section": "GA", "votes": [10, 21] }
  ],
  "closed": false,
  "created_at": "2026-10-04T22:10:00.000Z"
}
```

`by_section` splits the same counts by ticket tier, in option order — use it to
drive per-section effects.

### GET /v1/events/{id}/polls

List the event's polls with live results, newest first. **Scope:** `events:read`.

### POST /v1/events/{id}/polls/{poll_id}/close

Stop accepting votes and return the poll with its final results.
**Scope:** `events:write`. Closing a closed poll is a no-op.

### POST /v1/events/{id}/polls/{poll_id}/votes

Cast a ticket's vote. **Scope:** `tickets:manage`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ticket_id` | string | Yes | A ticket for this event. |
| `option` | integer | Yes | Index into the poll's `options`, starting at `0`. |

Returns `{ "voted": true }`.

> Errors: `400 invalid_option`, `404 resource_missing` (not your ticket),
> `404 poll_not_found` (unknown poll, or the ticket belongs to another event),
> `409 not_attended`, `409 already_voted`, `409 poll_closed`.

## Crafting

A customer's **stubs** are their checked-in tickets across all of your events
that have not been spent yet. Crafting spends `stubs_required` of them — oldest
first — for one grant of the event's reward. The reward is an ordinary
[perk](#api-perks) grant: it appears in `GET /v1/customers/{id}/perks` and is
redeemed with `POST /v1/perks/redeem`.

### GET /v1/events/{id}/crafting

A customer's progress. **Scope:** `tickets:read`. Query: `customer_id` (required).

```json
{
  "stubs_required": 5,
  "stubs_available": 3,
  "reward_name": "Season pass",
  "reward_description": null,
  "crafted": false
}
```

### POST /v1/events/{id}/craft

Spend the stubs and issue the reward. **Scope:** `tickets:manage`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `customer_id` | string | Yes | The customer crafting the reward. |

```json
{ "crafted": true, "perk_code": "grant_3f7a9c0d-1e2f-4a5b-8c6d-7e8f9a0b1c2d" }
```

One reward per customer per event. A spent ticket stays a valid stub in every
other respect — it only stops counting toward crafting.

> Errors: `409 not_enabled` (the event has no crafting rule),
> `409 already_crafted`, `409 not_enough_stubs` (the body also carries
> `stubs_required` and `stubs_available`), `409 conflict` (the customer's
> tickets changed mid-request — retry), `404 resource_missing` (not your event
> or customer).
