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_tokencollection 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/jsonwith 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). Thecurrencyfield is an ISO-4217 code (DKK,EUR). - IDs are integers; slugs and tokens are strings.
-
PDF endpoints return
application/pdfas 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
Fetch the organization your API key is scoped to. Use this to render the org's branding in your UI.
curl -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/organization
{
"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
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).
curl -H "Authorization: Bearer $TOK" \
"https://linjerum.com/api/v1/events?limit=20"
{
"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
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.
curl -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/events/hamlet-2026/availability
{
"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
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
curl -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/events/hamlet-2026
{
"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
List all venues belonging to your organization.
curl -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/venues
{
"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
Fetch a single venue.
curl -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/venues/7
{
"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
List all published attractions for your organization.
curl -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/attractions
{
"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
Fetch a single attraction with its ticket types and current availability.
curl -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/attractions/louisiana-museum
{
"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
Lightweight availability snapshot for polling. Passing an optional date
query parameter (ISO-8601 YYYY-MM-DD) adds a date_remaining field for
daily capacity.
curl -H "Authorization: Bearer $TOK" \
"https://linjerum.com/api/v1/attractions/louisiana-museum/availability?date=2026-05-01"
{
"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
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 |
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
{
"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
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 } |
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
{ "available": true }
{
"available": false,
"conflicts": [
{ "section_id": 12, "row": "A", "seat_number": 3 }
]
}
GET
/venues/:id/seat-plan
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 |
curl -H "Authorization: Bearer $TOK" \
"https://linjerum.com/api/v1/venues/7/seat-plan?event_id=123"
{
"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
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.
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
{
"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
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.
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
{
"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
}
}
{ "valid": false, "reason": "expired" }
Purchases
The purchase lifecycle has three phases:
-
Create —
POST /purchasesatomically locks the tickets and returns a Stripeclient_secret. -
Confirm — your frontend uses Stripe.js with the
client_secretto collect payment. -
Verify — our webhook processes
payment_intent.succeededand your backend pollsGET /purchases/:tokenuntilpaid_atis set.
Pending purchases that are never paid are auto-cleaned after 30 minutes.
POST
/purchases
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: trueresponse 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.
curl -X POST \
-H "Authorization: Bearer $TOK" \
-H "Content-Type: application/json" \
-d @purchase.json \
https://linjerum.com/api/v1/purchases
{
"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"
}
{
"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"
}
}
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
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 |
curl -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/purchases/aB3f_x9...
{
"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
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_atis set, use a refund flow instead — returns409 already_paid. -
Idempotent from the partner's view — a second cancel on the same
token returns
404 not_foundbecause the record is already gone.
Errors: 404 not_found, 409 already_paid.
curl -X POST -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/purchases/$TOKEN/cancel
{ "cancelled": true, "token": "abc123..." }
POST
/purchases/:token/resend-email
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.
curl -X POST -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/purchases/$TOKEN/resend-email
{ "enqueued": true, "token": "abc123..." }
POST
/tickets/:code/scan
Atomic "verify + redeem" for gate-scanner apps. Looks up the ticket by
code, and if it's currently valid:
-
Transitions the status to
scanned -
Increments
scan_count -
Records a
TicketChangeentry (valid→scanned, attributed to your API key) -
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)
curl -X POST -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/tickets/A1B2C3D4E5F6/scan
{
"valid": true,
"reason": "ok",
"ticket": {
"id": 9911,
"code": "A1B2C3D4E5F6",
"status": "scanned",
"scan_count": 1,
...
}
}
{
"valid": false,
"reason": "already_scanned",
"ticket": { "id": 9911, "status": "scanned", "scan_count": 1, ... }
}
GET
/tickets/:code/verify
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.
curl -H "Authorization: Bearer $TOK" \
https://linjerum.com/api/v1/tickets/A1B2C3D4E5F6/verify
{
"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
}
}
{
"valid": false,
"reason": "already_scanned",
"ticket": { "id": 9911, "code": "A1B2C3D4E5F6", "status": "scanned", ... }
}
GET
/purchases/:token/tickets/pdf
Return a PDF binary containing all tickets for a paid purchase.
Returns 409 not_yet_paid if the purchase hasn't been confirmed yet.
curl -H "Authorization: Bearer $TOK" -o tickets.pdf \
https://linjerum.com/api/v1/purchases/$TOKEN/tickets/pdf
GET
/purchases/:token/tickets/:id/pdf
Return a PDF for a single ticket in the purchase.
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
-
Backend: your server calls
POST /api/v1/embed/seat-plannerwith your API key and anevent_slug. We return a signed iframe URL. -
Frontend: you render an
<iframe src="…">on your page. -
The iframe loads the map, lets the user pick seats, and posts
linjerum:seats-changedmessages up to your page as the selection changes. -
Backend: when the user clicks your own "Continue" button, you
take the latest
seatsarray and POST it to/api/v1/purchasesas 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
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 |
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
{
"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_originscan embed. Other origins getframe-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
Response:
Drop the URL into <iframe src="…"> on a page whose origin is in the
API key's allowed_origins list.
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}'
{
"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_planor any other write path. Section changes, image uploads, and seating configuration changes are all in-memory only. -
Same
allowed_originsgate 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
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.
/embed/seat-plan-editor/embed/seat-planner(waiting for activity…)