Stored in your browser only (localStorage). Used by "Try it" runners below.

LINJERUM Partner API

A minimal headless API for partners building their own ticket-buying frontends backed by LINJERUM. Fetch events and seat plans, create bookings, confirm payments with Stripe.js, and retrieve ticket PDFs.

Base URL

https://linjerum.com/api/v1

Getting started

1. Create an API key. Sign in as an organization admin, go to Settings → API (open directly), click Create API key, and save. Copy the raw token immediately — it looks like tk_live_... and is shown only once (we store a hash). If you lose it, revoke and create a new one. Each key is scoped to exactly one organization.

2. Configure allowed_origins (only if you'll embed the iframe). For the embeddable seat planner or embeddable seat plan editor, add your frontend origin (e.g. https://teaterbilletten.dk) to the key's allowed_origins list. Server-to-server requests don't need this.

3. Make your first request. Pass the token as a bearer token:

curl -H "Authorization: Bearer tk_live_..." \
  https://linjerum.com/api/v1/organization

A successful response confirms the key is working. From here, skip to Quick start for an end-to-end booking flow.

4. Keep the token on your backend. Never ship the raw token to a browser. Mint short-lived signed URLs (for iframes) or proxy API calls through your own backend. If a token leaks, revoke it in Settings → API and issue a new one — in-flight requests with the old token start returning 401 unauthorized immediately.


Quick start

End-to-end booking flow. Replace tk_live_... with the token you created in Getting started.

TOK="tk_live_..."

# 1. Fetch an event
curl -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/events/hamlet-2026

# 2. Fetch the seat plan
curl -H "Authorization: Bearer $TOK" \
  "https://linjerum.com/api/v1/venues/7/seat-plan?event_id=123"

# 3. Create a purchase — returns a Stripe client_secret
curl -X POST -H "Authorization: Bearer $TOK" \
     -H "Content-Type: application/json" \
     -d @purchase.json \
     https://linjerum.com/api/v1/purchases

# 4. Confirm in the browser with Stripe.js + card 4242 4242 4242 4242

# 5. Poll for confirmation
curl -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/purchases/$TOKEN

# 6. Download ticket PDFs
curl -H "Authorization: Bearer $TOK" -o tickets.pdf \
  https://linjerum.com/api/v1/purchases/$TOKEN/tickets/pdf

Tooling

Two machine-readable versions of this spec are published publicly so you can skip hand-typing endpoints into your tooling:

  • OpenAPI 3.0 spec — feed into your client generator of choice (openapi-generator, orval, kubb, etc.) for typed clients in TypeScript, Python, Go, Ruby, or Java. Also imports cleanly into Postman, Insomnia, Bruno, and every modern API explorer.
  • Postman collection — drop-in Postman v2.1 collection grouped by tag. Import the URL, set the api_token collection variable once, and every endpoint is pre-wired with bearer auth.

Both are public (no token required to fetch). The OpenAPI spec regenerates on every deploy; no maintenance from your side.


Conventions

  • All JSON bodies use application/json with UTF-8.
  • Timestamps are ISO-8601 in UTC (2026-05-01T19:00:00Z).
  • Money is always sent in the smallest currency unit (price_cents, amount_cents). The currency field is an ISO-4217 code (DKK, EUR).
  • IDs are integers; slugs and tokens are strings.
  • PDF endpoints return application/pdf as a binary body.

Response headers

Every /api/v1/* response includes:

Header Meaning
X-Request-ID UUID for this request. Include it in support tickets so we can look up your call in our logs. If you pass your own X-Request-ID header on the way in, we echo it back.
X-RateLimit-Limit The per-minute cap on your API key
X-RateLimit-Remaining How many requests you have left in the current window

429 rate_limited responses additionally include:

Header Meaning
Retry-After Seconds until the oldest queued request falls out of the window — earliest safe time to retry

Activity log

Every API call is recorded and visible to organization admins in Settings → API under "Recent API activity". Rows show timestamp, method, path, status, duration, which key made the call, and the request ID. Kept for 7 days. Useful for self-diagnosing integration issues without pinging support.


Rate limiting

Every API key has a general per-minute limit (rate_limit_per_minute, default 120). In addition, specific endpoints enforce stricter dedicated buckets:

Endpoint Extra limit (per key)
POST /discount-codes/validate 20 / min
GET /purchases/:token/tickets/pdf (all and per-ticket) 10 / min

Exceeding any limit returns 429 Too Many Requests:

{ "error": "rate_limited" }

Authentication throttling. Failed authentication attempts are counted per source IP. After 10 bad attempts within a 60-second rolling window, further requests from that IP are rejected with:

{ "error": "too_many_auth_failures" }

This protects against token spray attacks. Successful authentication does not count against this counter.

All rate limits reset on a rolling 60-second window.


Errors

All errors return JSON with an error code and an optional detail string. Never parse human-readable text — branch on error.

{ "error": "insufficient_availability" }
Status Code Meaning
401 unauthorized Missing, invalid, or revoked API key
404 not_found Resource missing, draft, or not in your org
409 insufficient_availability Ticket types sold out mid-request
409 seats_taken One or more requested seats are now taken
409 max_uses_reached Discount code exhausted
409 not_yet_paid Purchase not confirmed; cannot download PDFs
409 daily_capacity_exceeded Attraction ran out of capacity on visit_date
409 idempotency_in_flight Retry while the original request still running
409 already_paid Tried to cancel a purchase that is already paid
422 invalid_request Malformed payload (see detail)
422 invalid_discount_code Discount code invalid or expired
422 idempotency_key_reuse Same key sent with a different request body
429 rate_limited Over the per-minute limit for this endpoint
429 too_many_auth_failures IP blocked after repeated bad tokens
500 internal_error Unexpected server-side failure
502 stripe_error Stripe PaymentIntent creation failed
503 payment_processing_unavailable Event's Stripe account not onboarded

Organization

GET /organization

Try it

Fetch the organization your API key is scoped to. Use this to render the org's branding in your UI.


Request
curl -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/organization
Response 200
{
  "organization": {
    "id": 42,
    "name": "Det Ny Teater",
    "description": "Klassiske og moderne forestillinger i hjertet af København.",
    "image_url": "https://cdn.linjerum.com/orgs/det-ny-teater.png",
    "primary_color": "#3B82F6",
    "currency": "DKK"
  }
}

Events

GET /events

Try it

List upcoming events for your organization. Returns events with an event_date in the future, sorted by date ascending. Cancelled and draft events are excluded.

Query parameters

Parameter Type Default Description
limit integer 25 Max events to return (max 100)

Use GET /events/:slug to fetch the full event (with ticket types and availability).

Request
curl -H "Authorization: Bearer $TOK" \
  "https://linjerum.com/api/v1/events?limit=20"
Response 200
{
  "events": [
    {
      "id": 123,
      "slug": "hamlet-2026",
      "name": "Hamlet",
      "event_date": "2026-05-01T19:00:00Z",
      "event_end_date": "2026-05-01T22:00:00Z",
      "sales_start_date": "2026-02-01T00:00:00Z",
      "sales_end_date": "2026-04-30T23:59:59Z",
      "venue": { "id": 7, "name": "Det Ny Teater", "city": "København" }
    }
  ],
  "count": 1
}

GET /events/:slug/availability

Try it

Lightweight availability snapshot for polling. Returns the per-type remaining count and the set of already-sold seats for the event. Much cheaper than refetching the full event payload.

Typical use: poll every 10–30 seconds while the user is on the seat selection screen so their view of available seats stays current.

Request
curl -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/events/hamlet-2026/availability
Response 200
{
  "event_id": 123,
  "status": "available",
  "ticket_types": { "45": 142, "46": 300 },
  "sold_seats": [
    { "section_id": 12, "row": "A", "seat_number": 5 },
    { "section_id": 12, "row": "A", "seat_number": 6 }
  ]
}

GET /events/:slug

Try it

Fetch an event with its ticket types and per-type availability. The event.status field tells you whether tickets can currently be sold.

Path parameters

Parameter Type Description
slug string Public slug of the event

Event status values

available · draft · cancelled · sales_not_started · sales_ended · sold_out


Request
curl -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/events/hamlet-2026
Response 200
{
  "event": {
    "id": 123,
    "slug": "hamlet-2026",
    "name": "Hamlet",
    "description": "…",
    "event_date": "2026-05-01T19:00:00Z",
    "event_end_date": "2026-05-01T22:00:00Z",
    "doors_open": "2026-05-01T18:30:00Z",
    "sales_start_date": "2026-02-01T00:00:00Z",
    "sales_end_date": "2026-04-30T23:59:59Z",
    "status": "available",
    "currency": "DKK",
    "venue": {
      "id": 7,
      "name": "Det Ny Teater",
      "address": "Gothersgade 8",
      "city": "København",
      "zip_code": "1123"
    }
  },
  "ticket_types": [
    {
      "id": 45,
      "name": "Voksen",
      "description": null,
      "price_cents": 35000,
      "currency": "DKK",
      "early_bird_price_cents": null,
      "early_bird_end_date": null,
      "early_bird_active": false,
      "category": "standard",
      "max_per_purchase": 8,
      "section_ids": [12, 13],
      "available": 142
    }
  ]
}

Venues

GET /venues

Try it

List all venues belonging to your organization.

Request
curl -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/venues
Response 200
{
  "venues": [
    {
      "id": 7,
      "name": "Det Ny Teater",
      "address": "Gothersgade 8",
      "city": "København",
      "zip_code": "1123",
      "seated_capacity": 840,
      "standing_capacity": 0,
      "floor_plan_width": 1200,
      "floor_plan_height": 800
    }
  ],
  "count": 1
}

GET /venues/:id

Try it

Fetch a single venue.


Request
curl -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/venues/7
Response 200
{
  "venue": {
    "id": 7,
    "name": "Det Ny Teater",
    "address": "Gothersgade 8",
    "city": "København",
    "zip_code": "1123",
    "seated_capacity": 840,
    "standing_capacity": 0,
    "floor_plan_width": 1200,
    "floor_plan_height": 800
  }
}

Attractions

Attractions are non-event ticket products — museums, guided tours, standing exhibitions. They do not have seat plans and do not have a fixed sales window; instead, each attraction can optionally require the buyer to pick a visit date and can enforce a per-day ticket cap via daily_max_tickets.

Attractions share the pricing, discount-code, purchase, and PDF-download endpoints with events — use attraction_slug (instead of event_slug) in the request body and the rest of the flow is identical, minus seat selection.

GET /attractions

Try it

List all published attractions for your organization.

Request
curl -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/attractions
Response 200
{
  "attractions": [
    {
      "id": 12,
      "slug": "louisiana-museum",
      "name": "Louisiana Museum",
      "visit_date_required": true,
      "daily_max_tickets": 300,
      "venue": { "id": 9, "name": "Louisiana", "city": "Humlebæk" }
    }
  ],
  "count": 1
}

GET /attractions/:slug

Try it

Fetch a single attraction with its ticket types and current availability.

Request
curl -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/attractions/louisiana-museum
Response 200
{
  "attraction": {
    "id": 12,
    "slug": "louisiana-museum",
    "name": "Louisiana Museum",
    "description": "…",
    "status": "available",
    "visit_date_required": true,
    "daily_max_tickets": 300,
    "opening_hours": {
      "mon": { "open": "10:00", "close": "18:00" }
    },
    "currency": "DKK",
    "venue": {
      "id": 9,
      "name": "Louisiana",
      "address": "Gl. Strandvej 13",
      "city": "Humlebæk",
      "zip_code": "3050"
    }
  },
  "ticket_types": [
    {
      "id": 90,
      "name": "Voksen",
      "price_cents": 15000,
      "currency": "DKK",
      "category": "standard",
      "max_per_purchase": 10,
      "available": 999
    }
  ]
}

GET /attractions/:slug/availability

Try it

Lightweight availability snapshot for polling. Passing an optional date query parameter (ISO-8601 YYYY-MM-DD) adds a date_remaining field for daily capacity.


Request
curl -H "Authorization: Bearer $TOK" \
  "https://linjerum.com/api/v1/attractions/louisiana-museum/availability?date=2026-05-01"
Response 200
{
  "attraction_id": 12,
  "status": "available",
  "ticket_types": { "90": 999, "91": 500 },
  "daily_max_tickets": 300,
  "date": "2026-05-01",
  "date_remaining": 248
}

Seat plans

The seat planner is the core of the partner API. Fetch the seat map once, then use availability polling and the helper endpoints below while the user picks seats.

POST /events/:slug/seats/auto-select

Try it

Pick the best available seats for a ticket type. If near is provided, the algorithm prefers seats adjacent to the existing selection (same row first, then same section, then other sections). Returns a list of {section_id, row, seat_number} with up to count entries.

If fewer than count seats are available, the response contains as many as could be found and a smaller count. requested echoes the input.

Body parameters

Parameter Type Required Description
ticket_type_id integer yes The ticket type to pick seats for
count integer yes How many seats to pick
near array no List of {section_id, row, seat_number} to pick near
Request
curl -X POST \
  -H "Authorization: Bearer $TOK" \
  -H "Content-Type: application/json" \
  -d '{"ticket_type_id":45,"count":2}' \
  https://linjerum.com/api/v1/events/hamlet-2026/seats/auto-select
Response 200
{
  "seats": [
    { "section_id": 12, "row": "A", "seat_number": 7 },
    { "section_id": 12, "row": "A", "seat_number": 8 }
  ],
  "count": 2,
  "requested": 2
}

POST /events/:slug/seats/validate

Try it

Check whether a proposed seat selection is still available before committing a purchase. Useful for highlighting conflicts if another buyer grabbed a seat while the user was choosing.

Does not reserve the seats — it's a non-locking pre-check. The subsequent POST /purchases still re-validates under a database lock.

Body parameters

Parameter Type Required Description
selected_seats object yes Map of ticket_type_id → array of { section_id, row, seat_number }
Request
curl -X POST \
  -H "Authorization: Bearer $TOK" \
  -H "Content-Type: application/json" \
  -d '{"selected_seats":{"45":[{"section_id":12,"row":"A","seat_number":3}]}}' \
  https://linjerum.com/api/v1/events/hamlet-2026/seats/validate
Response 200 — available
{ "available": true }
Response 200 — conflicts
{
  "available": false,
  "conflicts": [
    { "section_id": 12, "row": "A", "seat_number": 3 }
  ]
}

GET /venues/:id/seat-plan

Try it

Fetch a venue's sections, rows, and the set of seats already sold for a given event. Use this to render your own seat-selection UI. The ticket_type_section_map tells you which ticket types are valid in which sections.

Path parameters

Parameter Type Description
id integer Venue ID

Query parameters

Parameter Type Required Description
event_id integer yes Event that scopes sold seats

Request
curl -H "Authorization: Bearer $TOK" \
  "https://linjerum.com/api/v1/venues/7/seat-plan?event_id=123"
Response 200
{
  "venue_id": 7,
  "event_id": 123,
  "sections": [
    {
      "id": 12,
      "name": "Parterre",
      "display_order": 0,
      "seating_mode": "seated",
      "section_type": "rectangle",
      "color": "#3B82F6",
      "position_x": 100.0,
      "position_y": 50.0,
      "rotation": 0.0,
      "width": 200.0,
      "height": 150.0,
      "inner_radius": null,
      "outer_radius": null,
      "start_angle": null,
      "end_angle": null,
      "rows": [
        { "identifier": "A", "start": 1, "end": 20, "row_offset_x": 0.0, "row_position_y": 0.0 }
      ]
    }
  ],
  "sold_seats": [
    { "section_id": 12, "row": "A", "seat_number": 5 }
  ],
  "ticket_type_section_map": {
    "45": [12, 13],
    "46": [14]
  }
}

Pricing

POST /pricing/preview

Try it

Preview the total cost of a proposed purchase without creating anything. No seats are locked and no discount usage is incremented. Use this to render a checkout summary before the user commits.

Body parameters

Parameter Type Required Description
event_slug string yes Slug of the event being priced
quantities object yes Map of ticket_type_id → integer quantity
discount_code string no Applied if valid; silently ignored if invalid

If discount_code is present but invalid/expired, it's silently omitted from the response (pricing.discount will be null). Use POST /discount-codes/validate to check a code explicitly.


Request
curl -X POST \
  -H "Authorization: Bearer $TOK" \
  -H "Content-Type: application/json" \
  -d '{"event_slug":"hamlet-2026","quantities":{"45":2},"discount_code":"SPRING25"}' \
  https://linjerum.com/api/v1/pricing/preview
Response 200
{
  "pricing": {
    "subtotal_cents": 70000,
    "discount_cents": 17500,
    "fee_cents": 3500,
    "total_cents": 56000,
    "currency": "DKK",
    "line_items": [
      {
        "ticket_type_id": 45,
        "ticket_type_name": "Voksen",
        "quantity": 2,
        "unit_price_cents": 35000,
        "line_total_cents": 70000,
        "is_early_bird": false
      }
    ],
    "discount": {
      "code": "SPRING25",
      "percentage": 25,
      "amount_cents": null
    }
  }
}

Discount codes

POST /discount-codes/validate

Try it

Validate a discount code against an event. Returns the discount's terms without applying it. Does not increment usage.

Body parameters

Parameter Type Required Description
event_slug string yes Slug of the event
code string yes The code the user entered

Possible reason values: not_found, not_yet_valid, expired, max_uses_reached, invalid_code.


Request
curl -X POST \
  -H "Authorization: Bearer $TOK" \
  -H "Content-Type: application/json" \
  -d '{"event_slug":"hamlet-2026","code":"SPRING25"}' \
  https://linjerum.com/api/v1/discount-codes/validate
Response 200 — valid
{
  "valid": true,
  "discount": {
    "code": "SPRING25",
    "percentage": 25,
    "amount_cents": null,
    "applies_to_all": true,
    "ticket_type_ids": [],
    "valid_from": null,
    "valid_until": "2026-05-01T00:00:00Z",
    "max_uses": 500,
    "current_uses": 132
  }
}
Response 200 — invalid
{ "valid": false, "reason": "expired" }

Purchases

The purchase lifecycle has three phases:

  1. CreatePOST /purchases atomically locks the tickets and returns a Stripe client_secret.
  2. Confirm — your frontend uses Stripe.js with the client_secret to collect payment.
  3. Verify — our webhook processes payment_intent.succeeded and your backend polls GET /purchases/:token until paid_at is set.

Pending purchases that are never paid are auto-cleaned after 30 minutes.

POST /purchases

Try it

Create a purchase. Validates availability, locks ticket types, creates the Stripe PaymentIntent, and returns the client secret.

Idempotency. Optionally send an Idempotency-Key header (any string up to 255 chars; a UUID is a good default). If the partner retries the same request — because of a network timeout, a process crash, whatever — the second request returns the first response instead of creating a duplicate purchase. Cached for 24 hours per API key.

  • Same key + same body → replays the first response. Watch for the Idempotent-Replay: true response header if you want to distinguish cached from fresh.
  • Same key + different body → 422 idempotency_key_reuse. Don't reuse a key for a new request.
  • Same key while the first request is still running → 409 idempotency_in_flight. Wait a moment and retry.

Omit the header if you don't need idempotency.

Body parameters

Pass exactly one of event_slug or attraction_slug. The other body fields are identical for both — except selected_seats (events only) and visit_date (attractions only).

Parameter Type Required Description
event_slug string one-of required Slug of the event being purchased
attraction_slug string one-of required Slug of the attraction being purchased
quantities object yes Map of ticket_type_id → integer quantity. Total must be between 1 and 20
buyer object yes { name, email, phone? } — email is validated
attendees array no Per-ticket attendee info when required by the event
selected_seats object no (events) Map of ticket_type_id → array of { section_id, row, seat_number }
visit_date string sometimes ISO-8601 YYYY-MM-DD. Required when the attraction has visit_date_required: true
discount_code string no Validated server-side; atomically counted
comment string no Freeform note (e.g. accessibility requirements)

Free events return payment.stripe_client_secret: null and the purchase is already marked paid.

Confirming payment

Use the returned stripe_client_secret with Stripe.js on your frontend. Do not pass stripeAccount to Stripe() — this is a platform-account PaymentIntent confirmed with the platform publishable key.

Request
curl -X POST \
  -H "Authorization: Bearer $TOK" \
  -H "Content-Type: application/json" \
  -d @purchase.json \
  https://linjerum.com/api/v1/purchases
Request body
{
  "event_slug": "hamlet-2026",
  "quantities": { "45": 2, "46": 1 },
  "buyer": {
    "name": "Simon Hindkaer",
    "email": "simon@example.com",
    "phone": "+4512345678"
  },
  "attendees": [
    { "ticket_type_id": 45, "index": 1, "name": "Simon", "email": "simon@example.com" },
    { "ticket_type_id": 45, "index": 2, "name": "Maja",  "email": "maja@example.com"  },
    { "ticket_type_id": 46, "index": 1, "name": "Kid",   "email": "simon@example.com" }
  ],
  "selected_seats": {
    "45": [
      { "section_id": 12, "row": "A", "seat_number": 3 },
      { "section_id": 12, "row": "A", "seat_number": 4 }
    ],
    "46": [
      { "section_id": 12, "row": "A", "seat_number": 5 }
    ]
  },
  "discount_code": "SPRING25",
  "comment": "wheelchair access"
}
Response 201
{
  "purchase": {
    "token": "aB3f_x9...",
    "status": "pending_payment",
    "paid_at": null,
    "amount_cents": 75000,
    "currency": "DKK",
    "subtotal_cents": 71500,
    "fee_cents": 3500,
    "total_cents": 75000,
    "tickets": [
      {
        "id": 9911,
        "ticket_code": "A7F3B2",
        "ticket_status": "pending_payment",
        "ticket_type_id": 45,
        "name": "Simon",
        "email": "simon@example.com",
        "amount_cents": 35000,
        "currency": "DKK",
        "seat": { "section_id": 12, "row": "A", "seat_number": 3 }
      }
    ]
  },
  "payment": {
    "stripe_publishable_key": "pk_live_...",
    "stripe_connected_account_id": "acct_abc",
    "stripe_client_secret": "pi_xyz_secret_...",
    "stripe_payment_intent_id": "pi_xyz"
  }
}
JavaScript
const stripe = Stripe(resp.payment.stripe_publishable_key);
const { error } = await stripe.confirmPayment({
  clientSecret: resp.payment.stripe_client_secret,
  confirmParams: { return_url: 'https://teaterbilletten.dk/confirmation' },
});

GET /purchases/:token

Try it

Poll a purchase until paid_at is set. Tickets transition from pending_payment to valid once our webhook processes payment_intent.succeeded.

Path parameters

Parameter Type Description
token string Purchase token from POST /purchases
Request
curl -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/purchases/aB3f_x9...
Response 200
{
  "purchase": {
    "token": "aB3f_x9...",
    "status": "paid",
    "paid_at": "2026-04-18T12:05:42Z",
    "stripe_status": "succeeded",
    "amount_cents": 75000,
    "currency": "DKK",
    "event": { "id": 123, "slug": "hamlet-2026", "name": "Hamlet" },
    "tickets": [
      {
        "id": 9911,
        "ticket_code": "A7F3B2",
        "ticket_status": "valid",
        "ticket_type_id": 45,
        "name": "Simon",
        "email": "simon@example.com",
        "amount_cents": 35000,
        "currency": "DKK",
        "seat": { "section_id": 12, "row": "A", "seat_number": 3 }
      }
    ]
  }
}

POST /purchases/:token/cancel

Try it

Cancel an unpaid purchase. Frees any held seats immediately (instead of waiting for the ~30-minute automatic sweep) and best-effort voids the Stripe PaymentIntent so the card isn't charged.

  • Scope: purchase must belong to your organization (else 404 not_found).
  • Only works on unpaid purchases. Once paid_at is set, use a refund flow instead — returns 409 already_paid.
  • Idempotent from the partner's view — a second cancel on the same token returns 404 not_found because the record is already gone.

Errors: 404 not_found, 409 already_paid.

Request
curl -X POST -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/purchases/$TOKEN/cancel
Response 200
{ "cancelled": true, "token": "abc123..." }

POST /purchases/:token/resend-email

Try it

Re-send the purchase confirmation email (with ticket PDFs attached) to the original buyer. Use this when a customer reports they never received their ticket email, or it got lost in spam.

  • Scope: purchase must belong to your organization.
  • Only works for paid purchases — unpaid purchases return 409 not_yet_paid.
  • Enqueued asynchronously via our email worker; response is 202 Accepted.
  • Rate-limited by the general per-key window; for abuse protection LINJERUM may dedup rapid duplicates silently.

Errors: 404 not_found, 409 not_yet_paid.

Request
curl -X POST -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/purchases/$TOKEN/resend-email
Response 202
{ "enqueued": true, "token": "abc123..." }

POST /tickets/:code/scan

Try it

Atomic "verify + redeem" for gate-scanner apps. Looks up the ticket by code, and if it's currently valid:

  1. Transitions the status to scanned
  2. Increments scan_count
  3. Records a TicketChange entry (validscanned, attributed to your API key)
  4. Returns a verdict with valid: true, reason: "ok" — the partner UI can admit the holder

For tickets that are not admissable (already scanned, refunded, cancelled, etc.) the call does not mutate anything — it returns the same verdict shape as GET /verify with valid: false and the appropriate reason.

  • Scope: ticket must belong to your organization.
  • Code lookup is case-insensitive and trimmed.
  • Idempotent from the "ok" path's perspective: a second scan of the same ticket returns valid: false, reason: "already_scanned".

Response 200 (first scan, admit the holder)

Response 200 (second scan, reject the holder)

Request
curl -X POST -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/tickets/A1B2C3D4E5F6/scan
Response
{
  "valid": true,
  "reason": "ok",
  "ticket": {
    "id": 9911,
    "code": "A1B2C3D4E5F6",
    "status": "scanned",
    "scan_count": 1,
    ...
  }
}
Response
{
  "valid": false,
  "reason": "already_scanned",
  "ticket": { "id": 9911, "status": "scanned", "scan_count": 1, ... }
}

GET /tickets/:code/verify

Try it

Read-only lookup by the short scan code (the QR payload on the ticket PDF). Use this in a partner-built gate app to display whether a ticket is valid before admitting the holder. Does not mark the ticket as scanned — it's purely a read.

  • Scope: ticket must belong to your organization (else 404 not_found).
  • Code lookup is case-insensitive and trimmed.

Response 200 (valid ticket)

Response 200 (not valid — still 200, branch on valid/reason)

Possible reason values: ok, already_scanned, refunded, resold, cancelled, pending_payment.

Request
curl -H "Authorization: Bearer $TOK" \
  https://linjerum.com/api/v1/tickets/A1B2C3D4E5F6/verify
Response
{
  "valid": true,
  "reason": "ok",
  "ticket": {
    "id": 9911,
    "code": "A1B2C3D4E5F6",
    "status": "valid",
    "ticket_type": "General admission",
    "event_slug": "hamlet-2026",
    "attraction_slug": null,
    "holder_name": "Simon Hindkær",
    "seat_row": "A",
    "seat_number": 3,
    "scan_count": 0
  }
}
Response
{
  "valid": false,
  "reason": "already_scanned",
  "ticket": { "id": 9911, "code": "A1B2C3D4E5F6", "status": "scanned", ... }
}

GET /purchases/:token/tickets/pdf

Try it

Return a PDF binary containing all tickets for a paid purchase.

Returns 409 not_yet_paid if the purchase hasn't been confirmed yet.

Request
curl -H "Authorization: Bearer $TOK" -o tickets.pdf \
  https://linjerum.com/api/v1/purchases/$TOKEN/tickets/pdf

GET /purchases/:token/tickets/:id/pdf

Try it

Return a PDF for a single ticket in the purchase.


Request
curl -H "Authorization: Bearer $TOK" -o ticket.pdf \
  https://linjerum.com/api/v1/purchases/$TOKEN/tickets/9911/pdf

Embeddable seat planner

If you don't want to build your own seat-selection UI, you can drop LINJERUM's interactive seat planner straight into your page as an <iframe>. The iframe runs on our servers, authenticates with a short-lived signed URL that your backend mints, and communicates with your page via window.postMessage.

When to use it

  • You want a seat planner with zero UI work on your side.
  • You're OK with LINJERUM's styling (primary color is driven by your organization's branding).
  • You want to keep the partner API for data + purchase, but offload the interactive map to us.

Flow at a glance

  1. Backend: your server calls POST /api/v1/embed/seat-planner with your API key and an event_slug. We return a signed iframe URL.
  2. Frontend: you render an <iframe src="…"> on your page.
  3. The iframe loads the map, lets the user pick seats, and posts linjerum:seats-changed messages up to your page as the selection changes.
  4. Backend: when the user clicks your own "Continue" button, you take the latest seats array and POST it to /api/v1/purchases as normal.

Setting allowed_origins

Before the iframe will load on your domain, the API key must have your frontend's origin(s) in its allowed_origins list. We use them to build a Content-Security-Policy: frame-ancestors header, so browsers block any other page from embedding your seat planner. LINJERUM's own pages (like this docs page's live demo) are always allowed via 'self'.

Set via iex when minting a key:

{:ok, %{raw_token: tok}} =
  TicketSystem.ApiKeys.create(org_id, "Teaterbilletten",
    allowed_origins: ["https://teaterbilletten.dk", "https://staging.teaterbilletten.dk"])

POST /api/v1/embed/seat-planner

Try it

Mint a signed iframe URL. Short-lived (15 minutes). Tokens are single-event: one token → one event. Generate a fresh one every time the user navigates to a page that needs the iframe.

Body parameters

Parameter Type Required Description
event_slug string yes Slug of the event to render
Request
curl -X POST -H "Authorization: Bearer $TOK" \
  -H "Content-Type: application/json" \
  -d '{"event_slug": "hamlet-2026"}' \
  https://linjerum.com/api/v1/embed/seat-planner
Response 200
{
  "embed": {
    "url": "https://linjerum.com/embed/seat-planner?token=abc123…",
    "token": "abc123…",
    "expires_in": 900,
    "event": { "id": 123, "slug": "hamlet-2026", "name": "Hamlet" }
  }
}

Embedding on your page

<iframe
  src="https://linjerum.com/embed/seat-planner?token=abc123…&max=4"
  width="100%"
  height="600"
  frameborder="0"
  style="border: 1px solid #ddd; border-radius: 8px;"
  allow="clipboard-write"
></iframe>

Query-string options

Parameter Type Description
token string The signed token from the POST above (required)
max integer Max seats the user can select (1–20). Omit for no cap

postMessage protocol

All messages are window.postMessage objects with a type field namespaced linjerum:*.

Iframe → your page

// Fires once when the iframe is ready
{ type: "linjerum:ready",
  event_id: 123, event_slug: "hamlet-2026", event_name: "Hamlet",
  max_selections: 4, currency: "DKK" }

// Fires every time the selection changes
{ type: "linjerum:seats-changed",
  seats: [
    { section_id: 12, row: "A", seat_number: 3 },
    { section_id: 12, row: "A", seat_number: 4 }
  ] }

// Fires when the user tries to add a seat past the cap
{ type: "linjerum:full", max_selections: 4 }

// Fires when the iframe's own content height changes. Use to auto-size
// the <iframe> element.
{ type: "linjerum:resize", height: 612 }

Your page → iframe

// Clear the current selection
iframe.contentWindow.postMessage({ type: "linjerum:reset" }, "*");

Complete example

<!doctype html>
<html>
  <body>
    <h1>Hamlet — pick your seats</h1>
    <iframe
      id="seat-planner"
      src="https://linjerum.com/embed/seat-planner?token=TOKEN_FROM_BACKEND&max=4"
      width="100%" height="600" frameborder="0"></iframe>
    <button id="continue" disabled>Continue to payment</button>

    <script>
      const iframe = document.getElementById("seat-planner");
      const continueBtn = document.getElementById("continue");
      let currentSeats = [];

      window.addEventListener("message", (ev) => {
        // Optional: verify ev.origin is https://linjerum.com
        if (!ev.data || typeof ev.data !== "object") return;

        switch (ev.data.type) {
          case "linjerum:ready":
            console.log("planner ready for event", ev.data.event_slug);
            break;

          case "linjerum:seats-changed":
            currentSeats = ev.data.seats;
            continueBtn.disabled = currentSeats.length === 0;
            break;

          case "linjerum:resize":
            iframe.height = ev.data.height;
            break;

          case "linjerum:full":
            alert("You've reached the limit of " + ev.data.max_selections + " seats.");
            break;
        }
      });

      continueBtn.addEventListener("click", async () => {
        // Hand the seats off to your backend which has the API key and
        // calls POST /api/v1/purchases with them.
        const res = await fetch("/api/my-backend/create-purchase", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({
            event_slug: "hamlet-2026",
            selected_seats: { "45": currentSeats },
            quantities: { "45": currentSeats.length }
          })
        });
        const { stripe_client_secret, stripe_publishable_key } = await res.json();
        // …then use Stripe.js with the client_secret
      });
    </script>
  </body>
</html>

Security notes

  • Token never touches the browser untrusted — your backend mints it with the API key; the browser only sees the signed token.
  • Signed and time-limited — Phoenix.Token with 15-minute TTL. A leaked token expires quickly and is bound to one event.
  • Framing is allowlisted — only origins in the API key's allowed_origins can embed. Other origins get frame-ancestors 'none'.
  • Token is single-event — it authenticates viewing, not purchasing. The iframe cannot create purchases directly; it only emits seat picks that your backend then uses with the authenticated API key.
  • No tracking — the iframe runs on our domain with no third-party scripts, cookies, or analytics.

Embeddable seat plan editor

The viewer above is read-only — your users pick seats in a plan that was already built by the venue organizer. If you want to let partners edit the seat plan (move sections around, add rows, rename seats) without building a designer UI yourselves, embed the editor iframe. It's the same interactive canvas LINJERUM organizers use internally.

When to use it

  • You run a venue management or admin product and want to offer "design your floor plan" without building the tool yourself.
  • You want edits to flow back to LINJERUM immediately — your partners save, and any viewer iframe on the same venue reloads automatically.

POST /api/v1/embed/seat-plan-editor

Try it

Response:

Drop the URL into <iframe src="…"> on a page whose origin is in the API key's allowed_origins list.

Request
curl -X POST https://linjerum.com/api/v1/embed/seat-plan-editor \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"venue_id": 42}'
Response
{
  "embed": {
    "url": "https://linjerum.com/embed/seat-plan-editor?token=…",
    "token": "…",
    "expires_in": 900,
    "venue": { "id": 42, "name": "Main Hall" }
  }
}

postMessage protocol (editor)

From the iframe to your page:

{ type: "linjerum:ready",         venue: {...}, unsaved_changes: false }
{ type: "linjerum:editor-dirty",  unsaved_changes: true }
{ type: "linjerum:resize",        height: 820 }

The editor iframe is read-only: edits render in-browser so partners can preview the layout, but nothing persists. Full-featured editing happens on the organizer page inside LINJERUM itself.

Security notes (editor)

  • Token is venue-scoped — it authenticates exactly one venue's layout for a read-only preview. A leaked token can only open that venue's editor UI; no writes are possible.
  • No persistence — the embed never calls save_floor_plan or any other write path. Section changes, image uploads, and seating configuration changes are all in-memory only.
  • Same allowed_origins gate as the viewer — only origins on the API key can frame it.

Try it live

Out of scope

The v1 API intentionally ships with a narrow surface to support a single design partner. The following are not available:

  • Self-service key rotation endpoint
  • Partner-facing dashboard or usage analytics
  • Outgoing webhooks (polling only)
  • Pagination, event search, or listing endpoints
  • Refund, cancel, scanning, or edit APIs
  • Attractions and season tickets
  • Queue/waiting-room enforcement in the API path
  • Multi-organization keys (one key = one org)
  • Public CORS — call the API from your own backend
Live demo

The editor on the left is a read-only preview — play with the layout, nothing persists. Click seats in the viewer to see linjerum:* messages below.

Editor · /embed/seat-plan-editor
Viewer · /embed/seat-planner
Last message (waiting for activity…)