Endpoint Reference
All endpoints live under https://www.sway.events/api/v1 and are scoped to the crew that owns the API key, sent as a bearer token:
curl https://www.sway.events/api/v1/artists \
-H "Authorization: Bearer sway_sk_YOUR_KEY"
List endpoints return the standard { data, pagination } envelope and cursor pagination; every single-resource endpoint returns the resource object directly, with no data wrapper. See Pagination and filtering for cursors, filters and the expand mechanism.
Every 4xx or 5xx response is an RFC 9457 problem (application/problem+json) with a stable code field. See Authentication for the key lifecycle, scopes, rate limits and the full error code table.
This page documents the 80 routes that take a crew's own data. The MCP server and webhooks are separate delivery mechanisms over the same data and have their own pages; see the closing section.
Key and crew
GET /v1/me
Key introspection: the "is my key alive" probe. Returns the identified key (id, type, scopes, rate limit and work limits, registered websites) and its crew.
No scope required. Publishable keys: no. Never cached (the answer is key-specific and must always be current).
{
"key": {
"id": "3f9a1c02-7b45-4c8e-9d21-0a1b2c3d4e5f",
"type": "secret",
"scopes": ["read:profile", "read:artists", "read:promoters", "read:events", "read:venues", "read:content"],
"rate_limit": { "per_minute": 120, "per_day": 20000 },
"limits": {
"requests_per_minute": 2400,
"crew_work_per_minute": 600,
"crew_work_per_day": 120000,
"bytes_per_day": 5368709120
},
"websites": []
},
"crew": { "id": "a2628d53-4f21-4c1e-9b7d-1a2b3c4d5e6f", "name": "Insomnia", "slug": "insomnia" }
}
rate_limit is the key's own work budget (what a cache miss spends); limits are the wider ceilings described on the Authentication page. No route-specific errors beyond the standard authentication ones.
GET /v1/usage
What the calling key has used of its limits right now: work this minute and today, the request ceiling, bytes served today, and whether the key is frozen after an unusual pattern of requests.
No scope required. Publishable keys: no. Never cached; reading it never spends work (cost 0).
{
"key": { "id": "3f9a1c02-7b45-4c8e-9d21-0a1b2c3d4e5f", "type": "secret" },
"plan": "studio",
"work": {
"minute": { "limit": 120, "used": 12, "remaining": 108, "reset_seconds": 41 },
"day": { "limit": 20000, "used": 340, "remaining": 19660, "reset_seconds": 61200 }
},
"crew_work": {
"minute": { "limit": 600, "used": 12, "remaining": 588, "reset_seconds": 41 },
"day": { "limit": 120000, "used": 340, "remaining": 119660, "reset_seconds": 61200 }
},
"requests": {
"minute": { "limit": 2400, "used": 15, "remaining": 2385, "reset_seconds": 41 }
},
"bytes": { "today": 1048576, "limit": 5368709120 },
"frozen": false
}
work is the key's own budget; crew_work is the same budget pooled across every key of the crew. No route-specific errors.
GET /v1/audit-log
The last calls the key made, newest first: time, method, path, status, caller address, cache outcome and duration. Kept seven days, at most 1000 entries.
No scope required. Publishable keys: no. Never cached (privacy: no-store).
| Parameter | Values | Default | Meaning |
|---|---|---|---|
limit | integer, 1 to 1000 | 100 | How many entries to return |
{
"data": [
{
"at": "2026-09-26T12:00:03.000Z",
"method": "GET",
"path": "/api/v1/events/4521",
"status": 200,
"ip": "203.0.113.7",
"request_id": "9f2c1a7e-4d3b-4e21-8c7a-1b2c3d4e5f60",
"cache": "hit",
"ms": 4
}
],
"count": 1
}
ip is the calling key's own observed address. cache is hit, miss, stale or null when the route is not cacheable.
Errors: validation_error (400) when limit is out of range.
POST /v1/keys/rotate
Replaces the calling key with a new one: same name, type, scopes, limits and websites. The old key keeps working for a grace period (so a partner can redeploy without a gap), then expires on its own.
Scope write:keys (opt-in). Publishable keys: no. Never cached. Does not take Idempotency-Key: the answer carries the new secret, and Sway never stores an answer that carries a secret, so there is nothing to replay to a retry.
| Field | Type | Required | Notes |
|---|---|---|---|
grace_hours | number | no | Whole number, 1 to 168, default 24: how long the old key still answers |
{
"key": "sway_sk_ab12...",
"record": {
"id": "7c5e2b41-...",
"name": "Storefront key",
"key_prefix": "sway_sk_ab12",
"last4": "wxyz",
"key_type": "secret",
"scopes": ["read:artists", "read:events"],
"storefront_origins": [],
"created_at": "2026-09-26T12:00:00.000Z",
"expires_at": null,
"rotated_from_id": "3f9a1c02-7b45-4c8e-9d21-0a1b2c3d4e5f"
},
"previous": { "id": "3f9a1c02-7b45-4c8e-9d21-0a1b2c3d4e5f", "expires_at": "2026-09-27T12:00:00.000Z" }
}
The full new secret (key) is shown once, in this response, exactly like at creation time: store it immediately.
Errors: validation_error (400, bad body or grace_hours), invalid_key (401, the key was already gone), conflict (409, this key already has a successor: rotate the key that replaced it instead), rate_limited (429, a key may rotate once per hour).
GET /v1/crew
The crew's profile plus the ids of the pages it manages.
Scope read:profile. Publishable keys: yes. Cached 300 s.
{
"id": "a2628d53-4f21-4c1e-9b7d-1a2b3c4d5e6f",
"name": "Insomnia",
"slug": "insomnia",
"pages": { "artists": [245, 512], "promoters": [697] }
}
No route-specific errors.
GET /v1/crew/pages
The pages the crew manages (artists, promoters, venues), each with its name and whether it is published. Unlike every other route, a draft page is named here: it is the crew's own list.
Scope read:profile. Publishable keys: no. Cached 300 s.
{
"data": [
{ "type": "artist", "id": 245, "name": "KROMATIEK", "image_url": "https://assets.sway.events/artists/245/cover.webp", "is_published": true, "page_url": "https://www.sway.events/artist/245" },
{ "type": "promoter", "id": 697, "name": "Insomnia Nights", "image_url": null, "is_published": false, "page_url": null }
]
}
image_url and page_url are null while the page stays unpublished. No route-specific errors.
GET /v1/genres
The genre taxonomy: stable ids and names, sorted by name. These ids are what the genre= filters of GET /v1/artists, GET /v1/events and GET /v1/events/search take.
No scope required. Publishable keys: yes. Cached 3600 s.
{
"data": [
{ "id": 12, "name": "Hard Groove" },
{ "id": 45, "name": "Techno" }
]
}
No route-specific errors.
Discovery
These four routes need no API key at all: they describe the API itself. Each is rate-limited to 60 requests a minute per caller address, answered with 429 rate_limited past that.
GET /v1/openapi.json
This API as an OpenAPI 3.1 document, generated from the same list of routes the API runs on, so it never describes a route that does not exist or misses one that does.
No key required. Cached 3600 s.
No route-specific errors beyond the per-address limit above.
GET /v1/scopes
Every scope a key can hold: what it grants, whether a publishable key may hold it, whether only the crew owner may grant it, and the routes it opens.
No key required. Cached 3600 s.
{
"data": [
{
"scope": "read:events",
"group": "read",
"default": true,
"publishable": true,
"owner_only": false,
"summary": "Read published events, their lineups, days, tickets on sale and availability.",
"routes": ["GET /v1/events", "GET /v1/events/:id", "GET /v1/events/:id/ticket-tiers"]
},
{
"scope": "write:checkout:free",
"group": "write",
"default": false,
"publishable": false,
"owner_only": true,
"summary": "Issue free (comp) orders. The crew owner grants it.",
"routes": ["POST /v1/checkout-sessions"]
}
]
}
The real response lists all seventeen scopes and every route each one opens.
GET /v1/changelog
What changed in the API, newest first, each date with the routes that shipped that day.
No key required. Cached 3600 s.
{
"data": [
{
"date": "2026-08-01",
"changes": ["Stripe checkout sessions for tickets, and newsletter sign-up."],
"routes": ["POST /v1/checkout-sessions", "POST /v1/newsletter-signup"]
}
]
}
GET /v1/status
Whether the API and what it depends on answer: the database, payments (Stripe) and email, each ok or down, probed at most once per interval so the route itself never becomes load. The whole is ok only when every component is.
No key required. Never cached.
{
"status": "ok",
"api_version": "v1",
"time": "2026-09-26T12:00:00.000Z",
"components": { "database": "ok", "payments": "ok", "email": "ok" }
}
Never a latency figure, a version or an error message: just ok or down.
Artists
Every route below accepts a publishable key.
GET /v1/artists
The roster: every artist the crew manages, by name, ascending. Roster artists are returned even while unpublished, since the roster is the crew's own data.
Scope read:artists. Cached 300 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
q | text, 2 to 100 characters | none | Name contains, matched as text |
genre | comma list of genre ids, at most 10 | none | Any of these genres |
updated_since | ISO 8601 date or date-time | none | Changed at or after this instant |
ids | comma list of ids, at most 50 | none | Only these artists |
limit | integer, 1 to 100 | 25 | Page size |
cursor | opaque string | none | From a previous page's next_cursor |
{
"data": [
{
"id": 245,
"name": "KROMATIEK",
"image_url": "https://assets.sway.events/artists/245/cover.webp",
"description": "Ghent-based techno artist.",
"genres": ["Techno", "Hard Groove"],
"links": { "spotify": "https://open.spotify.com/artist/xyz", "instagram": "https://instagram.com/kromatiek" },
"is_verified": true,
"managed": true,
"contact_emails": [{ "type": "booking", "value": "[email protected]" }]
}
],
"pagination": { "next_cursor": "eyJrIjoiS1JPTUFUSUVLIiwiaWQiOjI0NX0", "has_more": true, "limit": 25 }
}
contact_emails is managed-tier only and never appears inside links. Errors: validation_error (400, bad q, genre, updated_since, ids or limit), invalid_cursor (400).
GET /v1/artists/{id}
One artist of the roster, full representation, returned directly. A foreign artist (a lineup guest who is not managed by the crew) is not addressable this way.
Scope read:artists. Cached 300 s.
Same shape as a roster entry above. Errors: not_found (404, malformed id, unknown id, or an artist the crew does not manage).
GET /v1/artists/{id}/events
The artist's gig history and calendar: published events where the artist is a confirmed lineup member. Each item carries a light venue reference.
Scopes read:artists and read:events (both required). Cached 120 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
status | upcoming, past, all | upcoming | Which events to include |
limit | integer, 1 to 100 | 25 | Page size |
cursor | opaque string | none | From a previous page |
This feed does not accept from, to or expand.
{
"data": [
{
"id": 4521,
"title": "Overload",
"description": "A night of hard groove.",
"starts_at": "2026-09-12T20:00:00.000Z",
"ends_at": "2026-09-13T04:00:00.000Z",
"timezone": "Europe/Brussels",
"image_url": "https://assets.sway.events/events/4521/cover.webp",
"type": "club",
"page_url": "https://www.sway.events/event/4521",
"venue": { "id": 88, "name": "Jungle Bar" }
}
],
"pagination": { "next_cursor": null, "has_more": false, "limit": 25 }
}
Errors: not_found (404, malformed id or artist the crew does not manage), validation_error (400, bad status), invalid_cursor (400).
GET /v1/artists/{id}/ics
The artist's upcoming dates as an iCalendar feed (text/calendar) to subscribe to. At most 100 events, RFC 5545, lines folded at 75 octets, times in UTC. GET /v1/promoters/{id}/ics and GET /v1/events/{id}/ics, further down, are the same feed for a promoter's events and for one event.
Scopes read:artists and read:events (both required). Cached 300 s.
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Sway//Sway API v1//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:KROMATIEK
BEGIN:VEVENT
UID:[email protected]
DTSTAMP:20260926T120000Z
DTSTART:20260912T200000Z
DTEND:20260913T040000Z
SUMMARY:Overload
LOCATION:Jungle Bar\, Ghent
URL:https://www.sway.events/event/4521
END:VEVENT
END:VCALENDAR
The file is named artist-{id}.ics (Content-Disposition: inline). Errors: not_found (404, malformed id, or the artist is not managed or not published).
GET /v1/artists/{id}/presskits
The artist's public press kits, the default one first. Private kits and drafts are left out here; see GET /v1/presskits for the crew's own full list.
Scopes read:artists and read:content (both required). Cached 300 s.
{
"artist_id": 245,
"data": [
{
"id": "1b2c3d4e-5f60-4a1b-9c2d-3e4f5a6b7c8d",
"artist_id": 245,
"title": "KROMATIEK 2026 EPK",
"status": "public",
"protected": false,
"is_default": true,
"updated_at": "2026-09-01T10:00:00.000Z",
"url": "https://www.sway.events/artist/245/presskit/1b2c3d4e-5f60-4a1b-9c2d-3e4f5a6b7c8d"
}
]
}
Errors: not_found (404, malformed id or artist the crew does not manage).
Promoters
Every route below accepts a publishable key.
GET /v1/promoters
Every promoter the crew manages, full representation, sorted by name.
Scope read:promoters. Cached 300 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
q | text, 2 to 100 characters | none | Name contains |
ids | comma list of ids, at most 50 | none | Only these promoters |
limit | integer, 1 to 100 | 25 | Page size |
cursor | opaque string | none | From a previous page |
Same envelope and item shape as GET /v1/artists (promoter fields instead of artist fields; no genre filter). Errors: validation_error (400), invalid_cursor (400).
GET /v1/promoters/{id}
One managed, published promoter, full representation, returned directly.
Scope read:promoters. Cached 300 s.
Errors: not_found (404, malformed id or promoter the crew does not manage).
GET /v1/promoters/{id}/events
Published events of a managed promoter: the main feed a partner website is built on.
Scopes read:promoters and read:events (both required). Cached 60 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
status | upcoming, past, all | upcoming | Which events |
from / to | ISO 8601 date or date-time | none | Starting at or after / at or before |
expand | comma list of venue, lineup, ticket_tiers | none | Embed related resources |
limit | integer, 1 to 100 | 25 | Page size |
cursor | opaque string | none | From a previous page |
{
"data": [
{
"id": 4521,
"title": "Overload",
"description": "A night of hard groove.",
"starts_at": "2026-09-12T20:00:00.000Z",
"ends_at": "2026-09-13T04:00:00.000Z",
"timezone": "Europe/Brussels",
"image_url": "https://assets.sway.events/events/4521/cover.webp",
"type": "club",
"page_url": "https://www.sway.events/event/4521",
"venue": { "id": 88, "name": "Jungle Bar" }
}
],
"pagination": { "next_cursor": null, "has_more": false, "limit": 25 }
}
venue is a light { id, name } reference by default; expand=venue upgrades it to the full public-tier object, and expand=lineup / expand=ticket_tiers add those arrays to each item (same shapes as on GET /v1/events/{id}). Errors: validation_error (400, bad status, from, to or expand), invalid_cursor (400), not_found (404, malformed id or promoter the crew does not manage).
GET /v1/promoters/{id}/ics
The promoter's upcoming events as an iCalendar feed, the same shape as GET /v1/artists/{id}/ics (see Artists), downloaded as promoter-{id}.ics.
Scopes read:promoters and read:events (both required). Cached 300 s.
Errors: not_found (404, malformed id or promoter the crew does not manage).
GET /v1/promoters/{id}/venues
The venues where the promoter's published events take place, most used first.
Scopes read:promoters and read:venues (both required). Cached 300 s.
{
"data": [
{
"id": 88,
"name": "Jungle Bar",
"image_url": "https://assets.sway.events/venues/88/cover.webp",
"description": "A cellar club in Ghent.",
"location": { "address": "Nieuwewandeling 2, Ghent" },
"genres": [],
"links": null,
"capacity": { "max": 300, "layouts": { "standing": 300, "seated": 120 } },
"event_count": 14
}
]
}
Errors: not_found (404, malformed id or promoter the crew does not manage).
GET /v1/promoters/{id}/artists
The artists the promoter has booked on its published events, most booked first. Roster artists come at the full tier, the rest at the public tier.
Scopes read:promoters and read:artists (both required). Cached 300 s.
{
"data": [
{ "id": 245, "name": "KROMATIEK", "image_url": "https://assets.sway.events/artists/245/cover.webp", "description": "Ghent-based techno artist.", "genres": ["Techno"], "links": null, "is_verified": true, "managed": true, "contact_emails": [], "booking_count": 6 },
{ "id": 1042, "name": "CE$AR", "image_url": null, "description": null, "genres": ["House"], "links": null, "is_verified": false, "managed": false, "booking_count": 2 }
]
}
booking_count counts events, not sets: a same-night double booking still counts once. Errors: not_found (404, malformed id or promoter the crew does not manage).
Events
start_time and end_time on a lineup entry, and every time inside GET /v1/events/{id}/days and GET /v1/events/{id}/timetable, leave as RFC 3339 with the event's own UTC offset, for example 22:00:00+02:00 rather than 22:00:00+00:00: the digits are exactly what the organiser typed, and the instant is the correct one. They are null while the organiser keeps the timetable off (event-days.ts, apiSetTime).GET /v1/events/{id}/days follows the days the organiser declared in the festival-days settings, or treats the whole event as one day when none are declared; unscheduled holds sets with no time, or outside every declared day. GET /v1/events/{id}/timetable follows the organiser's own stage order, undeclared stages after them alphabetically, and lists stage-less sets last.Every route below accepts a publishable key.
GET /v1/events
The union feed of the crew graph: its promoters' events, its roster's confirmed gigs, and the agenda of the venues it manages. Each item is a light summary with a venue reference.
Scope read:events. Cached 60 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
status | upcoming, past, all | upcoming | Which events |
from / to | ISO 8601 date or date-time | none | Date window |
promoter_id | positive integer | none | One managed promoter |
artist_id | positive integer | none | One roster artist |
venue_id | positive integer | none | One venue of the graph |
genre | comma list of genre ids, at most 10 | none | Any of these genres |
city | text, 1 to 100 characters | none | Exact city name |
country | ISO 3166-1 alpha-2 | none | Country code |
near | lat,lng in decimal degrees | none | Centre of a radius search |
radius_km | number, 1 to 500 | 25 | Radius around near (needs near) |
updated_since | ISO 8601 date or date-time | none | Changed at or after this instant |
has_tickets | true, false | none | Whether the event has any ticket tier |
ids | comma list of ids, at most 50 | none | Only these events |
limit | integer, 1 to 100 | 25 | Page size |
cursor | opaque string | none | From a previous page |
This feed does not support expand: passing it returns validation_error (400). Use GET /v1/events/{id} or GET /v1/promoters/{id}/events for expanded data.
{
"data": [
{
"id": 4521,
"title": "Overload",
"description": "A night of hard groove.",
"starts_at": "2026-09-12T20:00:00.000Z",
"ends_at": "2026-09-13T04:00:00.000Z",
"timezone": "Europe/Brussels",
"image_url": "https://assets.sway.events/events/4521/cover.webp",
"type": "club",
"page_url": "https://www.sway.events/event/4521",
"updated_at": "2026-09-10T09:00:00.000Z",
"venue": { "id": 88, "name": "Jungle Bar" }
}
],
"pagination": { "next_cursor": "eyJrIjoiMjAyNi0wOS0xMlQyMDowMDowMC4wMDBaIiwiaWQiOjQ1MjF9", "has_more": false, "limit": 25 }
}
Errors: validation_error (400, expand supplied, bad filter value, or radius_km without near), invalid_cursor (400), not_found (404, promoter_id or artist_id names a page the crew does not manage).
GET /v1/events/search
The same feed as GET /v1/events, filtered to events whose title, description or confirmed lineup contains the search text.
Scope read:events. Cached 60 s.
Every filter of GET /v1/events, plus:
| Parameter | Values | Default | Meaning |
|---|---|---|---|
q | text, 2 to 100 characters | required | Matched as text against title, description and lineup |
Same envelope as GET /v1/events. Errors: validation_error (400, q missing or the wrong length, or any of the shared filters), invalid_cursor (400), not_found (404, promoter_id or artist_id outside the graph).
GET /v1/events/{id}
One published event of the crew graph, returned directly. venue (full public tier) and lineup are always embedded; genres is always present.
Scope read:events. Cached 60 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
expand | ticket_tiers | none | venue and lineup are already embedded, so this is the only value that adds anything |
When expand=ticket_tiers is present, the response also carries a ticket_fee object: { "percent": number, "fixed": number, "applies_to": "ticket" | "order" }, the service fee for this event's payee, in EUR. It is the same object as GET /v1/events/{id}/fees, which explains how to apply it.
{
"id": 4521,
"title": "Overload",
"description": "A night of hard groove.",
"starts_at": "2026-09-12T20:00:00.000Z",
"ends_at": "2026-09-13T04:00:00.000Z",
"timezone": "Europe/Brussels",
"image_url": "https://assets.sway.events/events/4521/cover.webp",
"type": "club",
"page_url": "https://www.sway.events/event/4521",
"venue": {
"id": 88,
"name": "Jungle Bar",
"image_url": "https://assets.sway.events/venues/88/cover.webp",
"description": "A cellar club in Ghent.",
"location": { "address": "Nieuwewandeling 2, Ghent" },
"genres": [],
"links": null
},
"lineup": [
{
"stage": "Main", "start_time": "2026-09-12T23:00:00+02:00", "end_time": "2026-09-13T01:00:00+02:00",
"artist_id": 245, "id": 245, "name": "KROMATIEK", "image_url": "https://assets.sway.events/artists/245/cover.webp",
"description": "Ghent-based techno artist.", "genres": ["Techno"], "links": { "spotify": "https://open.spotify.com/artist/xyz" },
"is_verified": true, "managed": true, "contact_emails": [{ "type": "booking", "value": "[email protected]" }]
},
{
"stage": null, "start_time": null, "end_time": null,
"artist_id": 1042, "id": 1042, "name": "CE$AR", "image_url": null, "description": null,
"genres": ["House"], "links": null, "is_verified": false, "managed": false
}
],
"genres": ["Techno"]
}
Lineup entries carry the artist's own DTO plus stage, start_time, end_time and artist_id: roster artists come at the full tier (with contact_emails), foreign guests at the public tier. A custom, non-linked act appears as a placeholder entry with artist_id: null and its typed name under name. The second lineup entry above has start_time/end_time: null because that set has no time recorded, not because the timetable is off; see the note above the section.
Errors: validation_error (400, unknown expand value), not_found (404, malformed id, unpublished event, or an event outside the crew's graph).
GET /v1/events/{id}/ticket-tiers
The event's ticket tiers: name, description, price, status, sale window and a checkout_url deep link to the Sway checkout. No stock counts, no orders, no buyer data. A small, fixed list: wrapped in { "data": [...] } but with no pagination and no cursor.
Scope read:events. Cached 30 s.
{
"data": [
{
"id": "301", "name": "Early Bird", "description": "Limited first release", "price": 15.0, "currency": "EUR",
"status": "sold_out", "sale_start": "2026-06-01T10:00:00.000Z", "sale_end": "2026-07-01T10:00:00.000Z",
"max_per_order": 4, "checkout_url": "https://www.sway.events/event/4521?ref=api"
},
{
"id": "302", "name": "Regular", "description": null, "price": 20.0, "currency": "EUR", "status": "on_sale",
"sale_start": "2026-07-01T10:00:00.000Z", "sale_end": "2026-09-12T20:00:00.000Z",
"max_per_order": 6, "checkout_url": "https://www.sway.events/event/4521?ref=api"
}
]
}
status is derived server-side: on_sale, scheduled, sold_out or off_sale. currency defaults to EUR. Errors: not_found (404).
GET /v1/events/{id}/availability
Whether people can buy right now: on sale, scheduled, sold out or over, per tier and for the event as a whole, with a "few left" flag. Never a stock count.
Scope read:events. Cached 30 s.
{
"event_id": 4521,
"status": "on_sale",
"next_sale_start": null,
"tiers": [
{ "id": "301", "name": "Early Bird", "status": "sold_out", "few_left": false, "sale_start": "2026-06-01T10:00:00.000Z", "sale_end": "2026-07-01T10:00:00.000Z" },
{ "id": "302", "name": "Regular", "status": "on_sale", "few_left": true, "sale_start": "2026-07-01T10:00:00.000Z", "sale_end": "2026-09-12T20:00:00.000Z" }
]
}
few_left is set once a tier's remaining stock is at or under a tenth of its initial stock (minimum 5), and only while it is on_sale. The event's own status can be no_tickets when the event has no tier at all. Errors: not_found (404).
GET /v1/events/{id}/fees
The service fee a buyer pays on this event's tickets, from the payee promoter's fee model: by default a percentage of each paid ticket's price plus a fixed amount per ticket.
Scope read:events. Cached 300 s.
{ "event_id": 4521, "percent": 0.03, "fixed": 0.3, "applies_to": "ticket" }
With applies_to: "ticket", each paid ticket carries round(price * percent + fixed) to the cent (a bundle is one ticket, a free ticket carries nothing), and total = subtotal + the sum of those fees. A few promoters keep an older model, applies_to: "order": total = subtotal + subtotal * percent + fixed, once per paid order. Either way the result matches the total the Stripe checkout charges. Falls back to the default per-ticket rate when the payee cannot be resolved, rather than failing. Errors: not_found (404).
GET /v1/events/{id}/days
The days the organiser declared in the event's festival-days settings, each with the sets that start in it; or the event as one single day when none are declared.
Scope read:events. Cached 60 s.
{
"event_id": 4521,
"timezone": "Europe/Brussels",
"timetable_published": true,
"data": [
{
"name": "Friday",
"date": "2026-09-12",
"starts_at": "2026-09-12T20:00:00+02:00",
"ends_at": "2026-09-13T06:00:00+02:00",
"slots": [
{ "name": "KROMATIEK", "artist_id": 245, "artists": [{ "id": 245, "name": "KROMATIEK", "managed": true }], "stage": "Main", "start_time": "2026-09-12T23:00:00+02:00", "end_time": "2026-09-13T01:00:00+02:00" }
]
}
],
"unscheduled": []
}
unscheduled holds sets that have no time, or that fall outside every declared day. With the timetable switched off, timetable_published is false and every day comes with an empty slots array. A B2B set is one slot with several artists; name is the organiser's custom name when set, otherwise the artists joined with " B2B ". Errors: not_found (404).
GET /v1/events/{id}/timetable
The timed sets by stage, the stages in the organiser's own declared order (the rest alphabetically, the stage-less ones last), each stage in running order.
Scope read:events. Cached 60 s.
{
"event_id": 4521,
"timezone": "Europe/Brussels",
"published": true,
"stages": [
{
"stage": "Main",
"slots": [
{ "name": "KROMATIEK", "artist_id": 245, "artists": [{ "id": 245, "name": "KROMATIEK", "managed": true }], "stage": "Main", "start_time": "2026-09-12T23:00:00+02:00", "end_time": "2026-09-13T01:00:00+02:00" }
]
}
]
}
A set with no time is not on a timetable and is left out entirely (it can still appear in GET /v1/events/{id}/days, under unscheduled). With the timetable switched off, published is false and stages is empty. Errors: not_found (404).
GET /v1/events/{id}/ics
The event as an iCalendar feed, the same shape as GET /v1/artists/{id}/ics (see Artists), for an "add to calendar" button. Downloaded as event-{id}.ics.
Scope read:events. Cached 300 s.
Errors: not_found (404, malformed id, event unpublished or outside the crew's graph, or the event has no start date).
GET /v1/events/{id}/schema-org
The event as schema.org JSON-LD (MusicEvent), ready to inject as-is in a partner page's <script type="application/ld+json"> for search engines.
Scope read:events. Cached 300 s.
{
"@context": "https://schema.org",
"@type": "MusicEvent",
"name": "Overload",
"url": "https://www.sway.events/event/4521",
"eventStatus": "https://schema.org/EventScheduled",
"eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
"description": "A night of hard groove.",
"startDate": "2026-09-12T20:00:00.000Z",
"endDate": "2026-09-13T04:00:00.000Z",
"image": ["https://assets.sway.events/events/4521/cover.webp"],
"location": {
"@type": "Place",
"name": "Jungle Bar",
"address": { "@type": "PostalAddress", "streetAddress": "Nieuwewandeling 2, Ghent", "addressLocality": "Ghent", "addressCountry": "BE" }
},
"performer": [{ "@type": "PerformingGroup", "name": "KROMATIEK" }, { "@type": "PerformingGroup", "name": "CE$AR" }],
"organizer": { "@type": "Organization", "name": "Insomnia Nights", "url": "https://www.sway.events/promoter/697" },
"offers": [{ "@type": "Offer", "name": "Regular", "price": 20.0, "priceCurrency": "EUR", "availability": "https://schema.org/InStock", "url": "https://www.sway.events/event/4521?ref=api", "validFrom": "2026-07-01T10:00:00.000Z" }]
}
performer is built from the timetable's sets (not the raw lineup), so it lists names once each and follows the same "timetable off" rule as the routes above. Errors: not_found (404).
GET /v1/events/{id}/gallery
The event's public photo albums, each with the photos that finished uploading (the first 200) and how many it holds in total.
Scope read:events. Cached 300 s.
{
"event_id": 4521,
"data": [
{
"id": 12, "name": "Warm-up", "description": null,
"photos": [{ "id": 501, "url": "https://assets.sway.events/events/4521/gallery/501.webp", "width": 1600, "height": 1067 }],
"photo_count": 84
}
]
}
Private albums and photos still uploading never appear. Errors: not_found (404).
GET /v1/events/{id}/partners
The partners of the event's promoters and venues, in display order: the same shape as GET /v1/partners (see Content), wrapped under { "event_id": 4521, "data": [...] }.
Scopes read:events and read:content (both required). Cached 300 s.
Errors: not_found (404, event unpublished or outside the crew's graph).
GET /v1/events/{id}/news
The event's published news, newest first, as markdown and as sanitised HTML: the same shape as GET /v1/news (see Content), wrapped under event_id.
Scopes read:events and read:content (both required). Cached 120 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
limit | integer, 1 to 100 | 25 | Page size |
cursor | opaque string | none | From a previous page |
Errors: not_found (404, event unpublished or outside the crew's graph), invalid_cursor (400).
GET /v1/events/{id}/presales
The event's presales that are not drafts, exactly as the public presale page shows them: teaser, registration window, whether registration is open right now.
Scope read:events. Cached 60 s.
{
"event_id": 4521,
"data": [
{
"token": "f4c8b1a2-...", "status": "open", "registration_open": true,
"registration_opens_at": "2026-08-01T09:00:00.000Z", "registration_closes_at": "2026-09-05T23:59:00.000Z",
"featured": true, "featured_mode": "homepage",
"teaser": { "title": "Early access", "subtitle": "First 100 only", "image_url": null, "description": "Register for a chance at the first release." },
"url": "https://www.sway.events/presale/f4c8b1a2-..."
}
]
}
The products, caps, selection rule, promoter and mailing list behind a presale are never exposed. Errors: not_found (404).
Venues
Every route below accepts a publishable key.
GET /v1/venues
The venues of the crew graph: the ones it manages (managed: true) and the ones hosting its published events, public tier, by name.
Scope read:venues. Cached 300 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
q | text, 2 to 100 characters | none | Name contains |
ids | comma list of ids, at most 50 | none | Only these venues |
limit | integer, 1 to 100 | 25 | Page size |
cursor | opaque string | none | From a previous page |
{
"data": [
{
"id": 88, "name": "Jungle Bar", "image_url": "https://assets.sway.events/venues/88/cover.webp",
"description": "A cellar club in Ghent.", "location": { "address": "Nieuwewandeling 2, Ghent" },
"genres": [], "links": null,
"capacity": { "max": 300, "layouts": { "standing": 300, "seated": 120 } },
"managed": true
}
],
"pagination": { "next_cursor": null, "has_more": false, "limit": 25 }
}
Errors: validation_error (400), invalid_cursor (400).
GET /v1/venues/{id}
One venue of the crew graph, public tier, with its capacity, returned directly. Reachable only when the crew manages it or it hosts a published event of the graph.
Scope read:venues. Cached 300 s.
Same item shape as above (with managed). Errors: not_found (404).
GET /v1/venues/{id}/events
The venue's agenda: every published event of the crew graph that plays there (all of them when the crew manages the venue). Same filters as GET /v1/events, without the page/artist/venue filters.
Scopes read:venues and read:events (both required). Cached 60 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
status | upcoming, past, all | upcoming | Which events |
from / to | ISO 8601 date or date-time | none | Date window |
limit | integer, 1 to 100 | 25 | Page size |
cursor | opaque string | none | From a previous page |
Same envelope and item shape as GET /v1/events. Errors: not_found (404, malformed id or venue outside the graph), validation_error (400), invalid_cursor (400).
GET /v1/venues/{id}/capacity
How many people the venue holds per room layout (standing, seated, theatre, cabaret, banquet, cocktail, classroom), and the largest figure: layouts are alternative setups of the same room, never zones that add up.
Scope read:venues. Cached 300 s.
{ "venue_id": 88, "max": 300, "layouts": { "standing": 300, "seated": 120 } }
max and layouts are null and {} when the venue states no capacity. Errors: not_found (404).
Content
Every route below accepts a publishable key.
GET /v1/news
The published news of one page the crew manages (a promoter, a venue or an artist), newest first, as markdown and as sanitised HTML. A post scheduled for later stays out until its hour. GET /v1/events/{id}/news (see Events) is the same feed for one event instead of a page.
Scope read:content. Cached 120 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
promoter_id / venue_id / artist_id | positive integer | none | Exactly one, naming a page the crew manages |
limit | integer, 1 to 100 | 25 | Page size |
cursor | opaque string | none | From a previous page |
{
"data": [
{
"id": 88, "title": "Lineup announced",
"body_markdown": "The full lineup is **out now**.",
"body_html": "<p>The full lineup is <strong>out now</strong>.</p>",
"hero_image_url": "https://assets.sway.events/news/88/hero.webp",
"published_at": "2026-08-20T09:00:00.000Z"
}
],
"pagination": { "next_cursor": null, "has_more": false, "limit": 25 }
}
Errors: validation_error (400, not exactly one of promoter_id / venue_id / artist_id), invalid_cursor (400), not_found (404, the named page is not managed by the crew).
GET /v1/partners
The partners of one promoter or venue the crew manages, in display order.
Scope read:content. Cached 300 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
promoter_id / venue_id | positive integer | none | Exactly one, naming a page the crew manages |
{
"data": [
{ "id": 9, "name": "Redbull", "logo_url": "https://assets.sway.events/partners/9/logo.webp", "website_url": "https://www.redbull.com", "owner": { "type": "promoter", "id": 697 } }
]
}
Errors: validation_error (400, not exactly one of promoter_id / venue_id), not_found (404, the named page is not managed by the crew).
GET /v1/presskits
The press kits of every artist the crew manages, with their visibility (public, private, draft) and whether a password protects them. A publishable key sees the public ones only; a secret key sees all three states.
Scope read:content. Cached 120 s.
{
"data": [
{ "id": "1b2c3d4e-5f60-4a1b-9c2d-3e4f5a6b7c8d", "artist_id": 245, "title": "KROMATIEK 2026 EPK", "status": "public", "protected": false, "is_default": true, "updated_at": "2026-09-01T10:00:00.000Z", "url": "https://www.sway.events/artist/245/presskit/1b2c3d4e-5f60-4a1b-9c2d-3e4f5a6b7c8d" }
]
}
No route-specific errors.
GET /v1/presskits/{id}
A press kit exactly as its own page shows it: the artist, the sections in the chosen order, their texts and files. A protected kit wants its password in the Presskit-Password header, from every caller, the managing crew included.
Scope read:content. Never cached (the answer depends on a header the cache does not see).
curl https://www.sway.events/api/v1/presskits/1b2c3d4e-5f60-4a1b-9c2d-3e4f5a6b7c8d \
-H "Authorization: Bearer sway_sk_YOUR_KEY" \
-H "Presskit-Password: secret"
{
"id": "1b2c3d4e-5f60-4a1b-9c2d-3e4f5a6b7c8d",
"artist_id": 245,
"title": "KROMATIEK 2026 EPK",
"status": "public",
"protected": false,
"is_default": true,
"updated_at": "2026-09-01T10:00:00.000Z",
"url": "https://www.sway.events/artist/245/presskit/1b2c3d4e-5f60-4a1b-9c2d-3e4f5a6b7c8d",
"artist": { "id": 245, "name": "KROMATIEK", "image_url": "https://assets.sway.events/artists/245/cover.webp", "description": "Ghent-based techno artist.", "genres": ["Techno"], "links": null, "is_verified": true, "managed": true, "contact_emails": [] },
"sections": ["hero", "music", "bio", "photos", "contact"],
"style": { "accent_color": "#111111", "hero_image_url": null },
"bio_version": "short",
"bio_overrides": {},
"press_quotes": [],
"files": [{ "id": "a1b2...", "type": "photo", "url": "https://assets.sway.events/presskits/.../photo.webp", "filename": "kromatiek-1.webp", "size_bytes": 240811 }]
}
A private kit opens only to the managing crew's secret key; a draft is never reachable. Errors: not_found (404, malformed id, kit not found, artist not managed, kit is a draft, or a private kit called with the wrong key type), password_required (401, missing or wrong password), rate_limited (429, at most 10 password attempts a minute per key and kit, and per visitor address when called with a publishable key).
GET /v1/forms
The forms of the crew and of the pages it manages, drafts left out, each saying whether it takes answers right now.
Scope read:content. Cached 120 s.
{
"data": [
{
"slug": "backstage-crew", "title": "Join the backstage crew", "description": null,
"availability": "open", "opens_at": null, "closes_at": null,
"owner": { "type": "promoter", "id": 697 },
"url": "https://www.sway.events/f/backstage-crew"
}
]
}
availability is one of draft, scheduled, open, full, closed, empty (a published form with no question). Errors: none beyond authentication.
GET /v1/forms/{slug}
A form's questions, design and availability, to render it on the partner site, exactly as its own public page would.
Scope read:content. Cached 60 s.
{
"id": "9f2c1a7e-4d3b-4e21-8c7a-1b2c3d4e5f60",
"slug": "backstage-crew",
"title": "Join the backstage crew",
"description": null,
"availability": "open",
"opens_at": null,
"closes_at": null,
"captures_contact": true,
"definition_version": 3,
"settings": { "show_progress": true, "submit_label": null, "consent_text": null },
"steps": [{ "id": "s1", "fields": [{ "id": "email", "type": "email", "label": "Email", "required": true }] }],
"design": {},
"url": "https://www.sway.events/f/backstage-crew"
}
steps and design are the form's definition as the form builder stores it, passed through as they are: the answers you post to POST /v1/forms/{slug}/submit are keyed by the question ids inside steps. captures_contact says whether an address given in the form goes into the organiser's contacts.
Errors: not_found (404, bad slug shape, draft form, or a form not owned by the crew or one of its pages).
Export
None of these routes accept a publishable key; each needs read:export plus the matching read scope. Never cached (no-store), and each is throttled to one export per resource per key every five minutes, past which the answer is rate_limited (429). Every file is capped at 10,000 lines and says whether it was cut with an X-Export-Truncated: true|false header.
GET /v1/export/events
GET /v1/export/artists
GET /v1/export/promoters
GET /v1/export/venues
The crew's published catalogue, one resource at a time, as newline-delimited JSON (application/x-ndjson): one JSON object per line, no envelope, no pagination. Each line is exactly the DTO the matching list route hands out (GET /v1/events, GET /v1/artists, GET /v1/promoters, GET /v1/venues), so an export never shows more than the API already does.
Scopes read:export + read:events / read:artists / read:promoters / read:venues respectively.
{"id":4521,"title":"Overload","starts_at":"2026-09-12T20:00:00.000Z", ..., "venue":{"id":88,"name":"Jungle Bar"}}
{"id":4522,"title":"Warehouse Night","starts_at":"2026-09-19T21:00:00.000Z", ..., "venue":null}
The response is downloaded as sway-events.ndjson, sway-artists.ndjson, sway-promoters.ndjson or sway-venues.ndjson (Content-Disposition: attachment). Errors: rate_limited (429, one export of this resource already ran for this key in the last five minutes).
Booking requests
POST /v1/booking-requests
Submits a booking request from a partner site into the crew's booking pipeline and notifies the crew. Every requested artist must be a managed roster artist.
Scope write:bookings (opt-in). Publishable keys: no. Never cached. Takes Idempotency-Key. Rate-limited tighter than reads: 10 requests a minute per visitor (client_ip), and 300 a day per crew.
| Field | Type | Required | Notes |
|---|---|---|---|
artist_ids | number[] | yes | Non-empty, at most 50 ids, each a managed roster artist |
contact_email | string | yes | Valid email, at most 320 characters |
contact_name | string | no | At most 200 characters |
contact_phone | string | no | At most 200 characters |
event_name | string | no | At most 200 characters |
event_date | string | no | ISO date, YYYY-MM-DD |
venue | string | no | At most 200 characters |
city | string | no | At most 200 characters |
fee_offer | string | no | Free-form, at most 200 characters |
capacity | string | no | Free-form, at most 200 characters |
ticket_price | string | no | Free-form, at most 200 characters |
organization | string | no | At most 200 characters |
{
"artist_ids": [245],
"contact_email": "[email protected]",
"contact_name": "Jane Doe",
"event_name": "Warehouse Night",
"event_date": "2026-11-21",
"venue": "Fuse",
"city": "Brussels",
"fee_offer": "800 EUR"
}
Response 201 Created, returned directly:
{ "id": "9f2c1a7e-4d3b-4e21-8c7a-1b2c3d4e5f60", "status": "new", "created_at": "2026-07-03T14:00:00.000Z" }
Errors: validation_error (422, bad body shape, or artist_ids naming an id outside the roster, listed under errors[].invalid_ids), rate_limited (429, per-visitor or per-crew-per-day limit).
Audience
None of these routes accept a publishable key.
POST /v1/newsletter-signup
Adds an email to a managed promoter's newsletter as a CRM contact, with a GDPR consent log entry.
Scope write:newsletter (opt-in). Never cached. Takes Idempotency-Key. Rate-limited to 10 requests a minute per visitor (client_ip), and 500 signups a day per promoter.
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | Valid email, at most 320 characters |
promoter_id | number | yes | A promoter the crew manages |
first_name / last_name | string | no | At most 200 characters |
locale | string | no | At most 10 characters |
country | string | no | At most 2 characters |
{ "email": "[email protected]", "promoter_id": 697, "first_name": "Jane" }
Response 201 Created: { "status": "subscribed" }.
Errors: validation_error (422, bad body, or promoter_id not managed by the crew), rate_limited (429).
POST /v1/newsletter-unsubscribe
Takes an email off a managed promoter's newsletter and off its mailing-list provider: same CRM status every later sync respects.
Scope write:newsletter (opt-in). Never cached. Takes Idempotency-Key. Rate-limited to 10 requests a minute per visitor (client_ip), and 500 a day per promoter.
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | Valid email |
promoter_id | number | yes | A promoter the crew manages |
Response 200 OK: always { "status": "unsubscribed" }, whether or not the address was on the list: the route must never tell a caller who is subscribed.
Errors: validation_error (422, bad body, or promoter_id not managed), rate_limited (429).
POST /v1/forms/{slug}/submit
Sends the answers of a form the partner site rendered from GET /v1/forms/{slug}: validated against the stored definition, filed in the owner's CRM when the form captures a contact, with api:form:{slug} recorded as the consent source.
Scope write:forms (opt-in). Never cached. Takes Idempotency-Key. Rate-limited to 60 submissions a minute per key and form, 5 a minute per visitor address (when client_ip is forwarded), and 5000 a day per form.
| Field | Type | Required | Notes |
|---|---|---|---|
answers | object | yes | Keyed by field id, as the form's own definition names them |
locale | string | no | Two-letter language tag, optionally with a region |
honeypot | string | no | A hidden field of the partner's own form: any text in it and the answer is silently dropped |
client_ip | string | no | The visitor's address, used only to count their submissions |
{ "answers": { "email": "[email protected]", "s1_q1": "Yes" } }
Response 201 Created: { "status": "received", "redirect_url": null } (a filled honeypot answers exactly the same, but stores nothing).
Errors: not_found (404, bad slug, draft form, or a form not owned by the crew), validation_error (422, malformed body or answers that fail the form's own validation, under errors[].param as answers.<field>), conflict (409, not_taking_answers when the form is not currently open, already_answered when the form only allows one answer per address and this one already answered), rate_limited (429).
Checkout
None of these routes accept a publishable key.
POST /v1/checkout-sessions
Creates a Stripe hosted-checkout session for a public storefront and returns its url. The buyer is redirected there, then returns to Sway's own /success or /cancel page, never the caller's site; fulfilment (order, tickets, confirmation email) is entirely Sway's.
Scope write:checkout (opt-in); a cart whose total is exactly zero additionally needs write:checkout:free. Never cached. Does not take Idempotency-Key: this route keeps its own guard instead (idempotency_key in the body, below). Rate-limited to 60 requests a minute per buyer (client_ip), 600 a minute per key, 5000 checkouts a day per crew, and 30 paid (or 10 free) checkouts a day per buyer email.
Everything money- and stock-authoritative is resolved server-side and can never be supplied or overridden by the caller: price, currency, the payee's Stripe account, the service fee and the stock reservation. The key's crew must manage the promoter that receives the funds, otherwise validation_error.
| Field | Type | Required | Notes |
|---|---|---|---|
event_id | number | yes | Must be published and in the crew's graph |
line_items | array | yes | 1 to 20 entries of { "tier_id": string, "quantity": number }; tier_id is a tier id from GET /v1/events/{id}/ticket-tiers, quantity 1 to 50 |
buyer_email | string | yes | Valid email, at most 320 characters: where the tickets are delivered |
idempotency_key | string | no | A UUID per buy click; a retry with the same value returns the same session |
discount_id | string | no | A coupon id for the event, from POST /v1/coupons/validate |
access_code | string | no | A presale or access code that unlocks a hidden tier |
reference | string | no | Free-form, at most 64 characters of [A-Za-z0-9._:-], echoed to analytics only |
marketing_consent | boolean | no | Ignored on this path; consent asserted by an API key is never trusted for CRM enrolment |
consent | object | no | { analytics, marketing } booleans plus version/at, forwarded to ad-attribution recording only |
client_ip / client_user_agent / event_source_url | string | no | Buyer-side signals for ad-attribution matching, and client_ip to count the buyer's checkouts. Never used for anything else |
Fields rejected with validation_error if supplied, because each is resolved server-side: amount / price / unit_amount, currency, any payee or stripe_account, application_fee_amount / fee, success_url / cancel_url / return_url, coupon / coupon_code / stripe_coupon_id, payment_method_types, user_id.
{
"event_id": 4521,
"line_items": [{ "tier_id": "8f14e45f-ceea-467a-9575-b2c45e4f2a11", "quantity": 2 }],
"buyer_email": "[email protected]",
"idempotency_key": "b1e2c3d4-5f60-4a1b-9c2d-3e4f5a6b7c8d"
}
Response 200 OK, returned directly:
{ "url": "https://checkout.stripe.com/c/pay/cs_live_...", "checkout_id": "chk_9f2c1a7e..." }
checkout_id follows only a paid session; a free (comp) order returns { "url": ... } alone, since there is no Stripe session to track afterwards.
A paid checkout holds its tickets, and one use of its coupon and of its access code, for the session's 30 minutes. What the buyer does not pay for comes back when the session expires; until then, a code whose last use is on hold answers validation_error.
write:checkout:free. Free and paid tickets cannot be combined in one order.Errors: missing_scope (403, no write:checkout, or a free order without write:checkout:free), not_found (404, event unpublished or outside the graph), validation_error (422, bad body, unknown or unavailable tier, quantity over max_per_order, mixed free and paid lines, or the payee not managed by the crew), conflict (409, the promoter's payment account is not ready), rate_limited (429, check Retry-After), upstream_error (502, Stripe could not create the session).
GET /v1/checkout-sessions/{id}
Where a checkout this crew opened stands, addressed by the checkout_id its creation returned, never the Stripe session id: open (with its payment link), complete or expired.
Scope write:checkout. Never cached (no-store).
{
"checkout_id": "chk_9f2c1a7e...",
"status": "open",
"payment_status": "unpaid",
"amount_total": 4552,
"currency": "eur",
"expires_at": "2026-09-26T13:00:00.000Z",
"reference": null,
"url": "https://checkout.stripe.com/c/pay/cs_live_..."
}
url is present only while status is open. Errors: not_found (404, malformed handle, or a checkout sealed for another crew).
POST /v1/checkout-sessions/{id}/expire
Ends an open checkout this crew opened and gives its tickets back to stock immediately, instead of waiting for Stripe's own timer. Idempotent: calling it again on an already-expired checkout just answers with its current state.
Scope write:checkout. Never cached. Takes Idempotency-Key.
Same response shape as GET /v1/checkout-sessions/{id}. Errors: not_found (404, same as above), conflict (409, the checkout was already paid: a refund goes through the organiser instead).
POST /v1/coupons/validate
Checks a promo code before the checkout: the discount_id to pass on, its terms, and the totals of the cart when one is sent, or why it does not apply. Answers 200 either way: a refused code is an answer, not an error.
Scope write:checkout. Never cached. Does not take Idempotency-Key (writeClass: check): it runs every time, up to 300 checks a minute per key, on top of the guess limits below.
| Field | Type | Required | Notes |
|---|---|---|---|
event_id | number | yes | Must be an event whose payee the crew manages |
code | string | yes | 1 to 64 printable characters |
line_items | array | no | The checkout's cart, same shape as POST /v1/checkout-sessions |
client_ip | string | no | The buyer's address, used only to count their misses |
{ "event_id": 4521, "code": "EARLY10", "line_items": [{ "tier_id": "8f14e45f-ceea-467a-9575-b2c45e4f2a11", "quantity": 2 }] }
{
"valid": true, "code": "EARLY10", "discount_id": "f4c8b1a2-...",
"percentage": 10, "amount": null, "products_remaining": 4,
"totals": { "currency": "EUR", "subtotal": 5000, "discount": 500, "total": 4500 }
}
A code belonging to another event, another organiser, or a switched-off one all answer { "valid": false, "code": "...", "reason": "not_found" }, indistinguishably. Other reason values: min_order (with min_order_amount), product_limit (with products_remaining), expired, usage_limit, not_open_here, not_applicable. The Stripe coupon id never leaves.
Errors: validation_error (422, bad body), not_found (404, event unpublished or outside the graph), validation_error (422, payee not managed by the crew), rate_limited (429, the per-minute check ceiling, or the guess limit below).
POST /v1/access-codes/validate
Checks a presale or access code: the hidden ticket tiers it unlocks (never listed by GET /v1/events/{id}/ticket-tiers), whether the organiser wants only those tiers shown, and the code's own per-order cap, or why it does not work.
Scope write:checkout. Never cached. Does not take Idempotency-Key (writeClass: check), same 300-checks-a-minute ceiling as coupons.
| Field | Type | Required | Notes |
|---|---|---|---|
event_id | number | yes | Must be an event whose payee the crew manages |
code | string | yes | 1 to 64 printable characters |
client_ip | string | no | The buyer's address, used only to count their misses |
{
"valid": true, "code": "VIP", "name": "Members", "exclusive": true, "max_per_order": 2,
"tiers": [{ "id": "8f14e45f-ceea-467a-9575-b2c45e4f2a11", "name": "Presale", "description": null, "price": 25.0, "currency": "EUR", "status": "on_sale", "sale_start": null, "sale_end": null, "max_per_order": 4, "checkout_url": "https://www.sway.events/event/4521?ref=api&code=VIP" }]
}
exclusive: true means the organiser wants only these unlocked tiers shown once the code is entered. The code's own internal ids never leave. Other refusal reason values: not_found, inactive, not_started, ended, usage_limit.
Guessing limits (both routes above). Beyond the general per-minute check ceiling, an unknown code is counted per key and event (300 misses per 10 minutes) and, when the caller forwards client_ip, per buyer address too (10 misses per 15 minutes): past either limit the answer is rate_limited (429) before the code is even looked up.
Errors: same as POST /v1/coupons/validate.
Shop
The three reads accept a publishable key; the quote and the checkout do not.
Merch sold by the pages your crew manages, for a storefront of your own: a page's catalogue, one product, what an event sells, the price of a cart, and a Stripe checkout on the selling page's own account. The buyer pays on Stripe and comes back to Sway's pages; Sway writes the order, emails the buyer and the seller, and the seller handles delivery, withdrawals and refunds from the page's Shop screen.
A page sells once its shop is open; until then its catalogue reads as empty. Prices include VAT and are in euro cents. No route here gives a stock count: a variant is available or not.
GET /v1/shop/products
The published merch of one of the crew's pages, with the seller identity buyers must see before they pay. Event exclusives are not in it: read them on the event.
Scope read:shop. Publishable keys: yes. Cached 30 s.
| Parameter | Type | Required | Notes |
|---|---|---|---|
seller_type | string | yes | promoter, artist or venue |
seller_id | number | yes | A page the crew manages |
{
"seller": {
"type": "promoter", "id": 697, "name": "Insomnia Nights", "image_url": null,
"legal_name": "Insomnia Nights SRL", "address": "Rue de la Loi 1, 1000 Brussels, BE",
"company_number": "BE0123456789", "contact_email": "[email protected]",
"vat_regime": "registered", "return_shipping_paid_by": "buyer"
},
"installments": { "provider": "stripe", "publishable_key": "pk_live_...", "stripe_account": "acct_1Abc...", "country": "BE", "methods": ["klarna"] },
"data": [
{
"id": "3f2c5e1a-7b4d-4c1e-9a2b-5d6e7f8a9b0c", "slug": "tour-hoodie-2026", "title": "Tour Hoodie 2026",
"description": "Heavyweight cotton.",
"images": [{ "url": "https://assets.sway.events/shop/hoodie.webp", "alt": null, "width": 1200, "height": 1200 }],
"options": ["Size"],
"variants": [{ "id": "9a1b7c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "options": ["M"], "price_cents": 4500, "available": true, "max_per_order": 20 }],
"from_price_cents": 4500, "currency": "EUR", "available": true,
"fulfilment_modes": ["delivery", "pickup_event"], "country_rule": "all", "countries": [], "exclusive": false,
"manufacturer": { "name": "Insomnia Nights SRL", "address": "Rue de la Loi 1, 1000 Brussels", "email": "[email protected]" },
"eu_responsible": null, "safety_info": "Wash at 30 degrees.", "preparation_days": 2, "preorder": null,
"seller": { "type": "promoter", "id": 697 }
}
]
}
A small list: wrapped in { "data": [...] } with no pagination. A page the crew does not manage, or whose shop is not open, answers { "seller": null, "data": [] }. fulfilment_modes lists delivery, pickup_event and pickup_location; country_rule is all, only or except, applied to countries. Errors: validation_error (422, missing or invalid seller_type or seller_id).
preorder is null, or { "status": "open", "ships_on": "2026-11-15", "ends_on": null } for a product sold before it is ready: it ships from ships_on and takes pre-orders until ends_on when one is set. status is closed once pre-orders ended before the shipping day; its variants then read available: false. On its shipping day the product sells like any other and preorder turns null.
installments is null, or Stripe's instalment offer on the selling page's account. When it is set, render Stripe's own Payment Method Messaging Element with publishable_key and stripe_account (Stripe.js), never words of your own: advertising instalment credit is regulated. methods lists what Checkout offers (klarna); country is the seller's, a default until the buyer picks a delivery country. The product and the event shop carry the same field.
GET /v1/shop/products/{id}
One product for its product page, returned directly, with seller holding the full seller identity. With event_id, the product as that event sells it, exclusives included.
Scope read:shop. Publishable keys: yes. Cached 30 s.
| Parameter | Type | Required | Notes |
|---|---|---|---|
event_id | number | no | An event of the crew's graph whose shop is open |
Errors: not_found (404, an unknown or unpublished product, a page the crew does not manage, an event exclusive asked without its event, or an event whose shop is closed).
GET /v1/events/{id}/shop
What an event of the crew's graph sells: its payee promoter's products, all of them or a selection, exclusives included, while the event shop is open; and whether buyers can still choose pickup at the event.
Scope read:shop. Publishable keys: yes. Cached 30 s.
{
"event_id": 4521,
"seller": { "type": "promoter", "id": 697, "name": "Insomnia Nights", "legal_name": "Insomnia Nights SRL" },
"pickup_at_event": { "open": true, "cutoff_at": "2026-10-10T20:00:00.000Z", "instructions": "Merch stand, left of the bar, from doors" },
"data": [{ "id": "3f2c5e1a-7b4d-4c1e-9a2b-5d6e7f8a9b0c", "title": "Tour Hoodie 2026", "exclusive": false }]
}
seller holds the full seller identity, as in the list above. The list is empty, with seller and pickup_at_event at null, while the event shop is closed, or when the payee promoter is not a page the crew manages, since the checkout could not sell for it. Errors: not_found (404, an unpublished event or one outside the graph).
POST /v1/shop/quote
Prices a cart before the checkout: the lines after the coupon, the delivery options for a mode and a country, the modes and countries the cart allows, and the total. Nothing is reserved.
Scope read:shop. Publishable keys: no. Never cached. Rate-limited to 120 requests a minute per buyer (client_ip).
| Field | Type | Required | Notes |
|---|---|---|---|
lines | array | yes | 1 to 20 entries of { "variant_id": string, "quantity": number }, quantity 1 to 20 |
mode | string | yes | delivery, pickup_event or pickup_location |
event_id | number | no | The event the cart comes from: it unlocks the event's exclusives and pickup at the event |
country | string | for delivery | Two-letter code of the delivery country |
coupon_code | string | no | A code of the selling page |
method_key | string | no | The delivery option the buyer picked, from shipping_options |
client_ip | string | no | The buyer's address, to count their requests |
{
"currency": "EUR",
"lines": [{ "variant_id": "9a1b7c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "name": "Tour Hoodie 2026 · M", "quantity": 2, "unit_cents": 4500, "discount_cents": 900 }],
"subtotal_cents": 9000,
"discount_cents": 900,
"shipping_options": [
{ "key": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "kind": "delivery", "name": "bpost, 2 to 4 days", "carrier": "bpost", "amount_cents": 590, "min_days": 2, "max_days": 4, "pickup_address": null, "instructions": null }
],
"selected_option": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"shipping_cents": 590,
"total_cents": 8690,
"available_modes": ["delivery", "pickup_event"],
"deliverable_countries": ["BE", "FR", "NL"],
"coupon": { "code": "TOUR10", "discount_cents": 900 },
"ships_on": null
}
ships_on is the day the order ships when the cart holds a pre-order, the latest of them; the buyer sees it before paying, on Stripe's page too. It is null for a cart in stock. not_ready_for_event refuses pickup at the event for a cart that ships after it; preorder_closed carries the variant_id whose pre-orders ended.
Errors: validation_error (422) with a reason: invalid_cart, invalid_mode, unavailable, out_of_stock, max_per_order, mixed_sellers, country_required, country_not_allowed, no_method, mode_not_allowed, pickup_closed, preorder_closed, not_ready_for_event, or a reason starting with coupon_. max_per_order carries variant_id and max; a delivery refusal carries available_modes and deliverable_countries. Then conflict (409, reason not_ready: the page cannot sell yet), not_found (404, an event outside the graph) and rate_limited (429).
POST /v1/shop/checkout-sessions
Opens a Stripe hosted checkout for merch of a page the crew manages, on that page's own Stripe account, and returns its url. The buyer pays there, then lands on Sway's thank-you page and their order page, or on /cancel, never on the caller's site. Sway writes the order, emails the buyer and the seller, and takes its commission from the seller.
Scope write:shop:checkout (opt-in). Publishable keys: no. Never cached. Does not take Idempotency-Key: send idempotency_key in the body. Rate-limited to 60 requests a minute per buyer (client_ip), 5000 checkouts a day per crew and 30 a day per buyer email.
The body is the quote's, plus:
| Field | Type | Required | Notes |
|---|---|---|---|
buyer_email | string | yes | Where the confirmation and the order link go |
idempotency_key | string | no | A UUID per pay click; a retry with the same value returns the same session |
locale | string | no | The buyer's language for Stripe and the emails: en, fr, de, es, it, nl or uk |
reference | string | no | Free-form, at most 64 characters of [A-Za-z0-9._:-], kept on the order |
Fields rejected with validation_error if supplied, because each is resolved server-side: amount, price, unit_amount, unit_cents, price_cents, total_cents, shipping_cents, currency, seller_type, seller_id, payee, stripe_account, application_fee_amount, fee, commission, success_url, cancel_url, return_url, discount_id, stripe_coupon_id, user_id.
{
"lines": [{ "variant_id": "9a1b7c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "quantity": 2 }],
"mode": "delivery",
"country": "BE",
"coupon_code": "TOUR10",
"buyer_email": "[email protected]",
"idempotency_key": "b1e2c3d4-5f60-4a1b-9c2d-3e4f5a6b7c8d"
}
Response 200 OK: { "url": "https://checkout.stripe.com/c/pay/cs_live_..." }
The stock is held for the session's 30 minutes; what the buyer does not pay for comes back when the session expires. A marketing consent sent by the caller is ignored: an API key never enrols a buyer in a CRM.
Errors: the quote's, plus validation_error for a bad buyer_email or reference, or for products of a page the crew does not manage; conflict (409, not_ready, or the stock moved under the cart); rate_limited (429, check Retry-After); upstream_error (502, Stripe could not create the session).
Ambassadors
The promoter's referral programme, rendered on the partner's own site instead of on sway.events. None of these routes accept a publishable key: run them from a server, never a browser. Three credentials are in play: the sway_sk_... API key (which crew is calling), an amb_st_... member session travelling in the X-Ambassador-Session header (which ambassador is signed in, minted at registration or sign-in, 8 hours), and the partner's own first-party cookie mapping a visitor to that session.
GET /v1/ambassador-program
Everything a "become an ambassador" page needs: the programme's name and description, the discount it offers, whether it is currently taking people, and the promoter's own words for XP, tiers, quests and badges. Public data only, no member and no member count.
Scope read:ambassadors. Cached 120 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
owner_type | promoter, venue | required | Whose programme |
owner_id | positive integer | required | The page's id |
{
"ok": true,
"program": { "name": "Insomnia Ambassadors", "description": "Bring your friends, earn rewards." },
"owner": { "type": "promoter", "id": 697 },
"discount": { "percentage": 15 },
"signup": { "open": true, "seats_left": 42 }
}
The answer also repeats name, description, signup_open and gamification_enabled at the top level, for sites built on the first version of the Insomnia boilerplate. Read the nested fields: they are the ones this page describes.
An owner outside the crew's graph answers the same 404 as one with no programme: this route must never say which promoters the key does not manage happen to run one.
Errors: validation_error (422, missing or malformed owner_type / owner_id: this route predates the 400 rule for query parameters and keeps its answer), not_found (404).
GET /v1/ambassador-leaderboard
The programme's leaderboard, only when the promoter publishes it: rank, first name and last initial, score. A revenue ranking gives the order, never the amounts.
Scope read:ambassadors. Cached 60 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
owner_type | promoter, venue | required | Whose programme |
owner_id | positive integer | required | The page's id |
limit | integer, 1 to 100 | 25 | How many ranks |
{
"metric": "tickets",
"data": [
{ "rank": 1, "name": "Melanie P.", "score": 42, "tier": { "name": "Gold", "color": "#f5c542" } },
{ "rank": 2, "name": "Tom D.", "score": 37, "tier": null }
]
}
metric is tickets, xp or revenue. With revenue, score is null: the order shows who sold most, never how much. tier is set only while the programme's tiers are on.
Errors: validation_error (400, owner_type / owner_id missing or malformed), not_found (404, no programme, or the programme does not publish its leaderboard).
GET /v1/ambassador-rewards
The rewards members spend their credits on: cost, what is left, when, and the tier they need. Empty while credits are switched off.
Scope read:ambassadors. Cached 60 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
owner_type | promoter, venue | required | Whose programme |
owner_id | positive integer | required | The page's id |
{
"credits_enabled": true,
"label_singular": "credit",
"label_plural": "credits",
"data": [
{
"id": "7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"name": "Backstage tour",
"description": "Meet the crew before doors open.",
"image_url": null,
"cost": 50,
"stock_left": 8,
"max_per_member": 1,
"available_from": null,
"available_until": "2026-12-31T23:00:00+00:00",
"available": true,
"event": { "id": 4521, "title": "Insomnia Night: Autumn Opening" },
"min_tier": { "id": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091", "name": "Gold", "min_xp": 500 },
"claim_prompt": "Which night suits you?"
}
]
}
stock_left is null when the reward has no stock limit. available is false outside its dates or once it is gone. claim_prompt is the question the member answers when claiming it (the answer of POST /v1/ambassador-claim).
Errors: validation_error (400, owner_type / owner_id), not_found (404, no programme).
GET /v1/ambassador-quests
The quests members can take on now: goal, dates and rewards. A signed-in member's own progress is in GET /v1/ambassador-me, never here.
Scope read:ambassadors. Cached 60 s.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
owner_type | promoter, venue | required | Whose programme |
owner_id | positive integer | required | The page's id |
{
"enabled": true,
"quest_noun": "quest",
"xp_label": "XP",
"badge_noun": "badge",
"data": [
{
"id": "3d4e5f60-7182-4a93-b4c5-d6e7f8091a2b",
"name": "Bring five friends",
"description": null,
"image_url": null,
"metric": "tickets",
"scope": "event",
"threshold": 5,
"event": { "id": 4521, "title": "Insomnia Night: Autumn Opening" },
"starts_at": null,
"ends_at": "2026-10-03T20:00:00+00:00",
"xp_reward": 100,
"credit_reward": 20,
"badge": { "id": "4e5f6071-8293-4a4b-85c6-e7f8091a2b3c", "name": "Recruiter", "image_url": null, "icon": "users", "rarity": "silver" }
}
]
}
metric is what the member has to reach threshold of: orders, tickets, revenue_cents, credits_earned, reward_claimed, profile_complete or manual (the organiser ticks it). scope is lifetime (since the member joined), event (on one event, or on any single event when event is null) or window (between starts_at and ends_at). data is empty while gamification is off.
Errors: validation_error (400, owner_type / owner_id), not_found (404, no programme).
GET /v1/ambassadors
The programme's members for the promoter's own tools, newest first: name, email, Instagram, code, status, sales, credits, XP and tier. Never the phone, the access token or the Sway account.
Scope read:ambassador-members (owner-only: only the crew owner grants it). Never cached (no-store).
| Parameter | Values | Default | Meaning |
|---|---|---|---|
owner_type | promoter, venue | required | Whose programme |
owner_id | positive integer | required | The page's id |
q | text | none | Matches name, email, Instagram or code |
status | active, pending, suspended | none | Filter by status |
limit | integer, 1 to 100 | 25 | Page size |
cursor | opaque string | none | From a previous page |
{
"data": [
{
"id": "5f0c6a0e-2b1d-4c3e-9f8a-7b6c5d4e3f21", "first_name": "Melanie", "last_name": "Pauwels",
"email": "[email protected]", "instagram_handle": "mel", "locale": "nl", "status": "active", "code": "MELANPAU",
"sales": { "orders": 4, "tickets": 9, "revenue_cents": 27000 }, "credit_balance": 12, "xp": 340, "tier": "Gold",
"joined_at": "2026-09-01T00:00:00.000Z"
}
],
"pagination": { "next_cursor": null, "has_more": false, "limit": 25 }
}
Errors: validation_error (400, owner_type / owner_id / status), invalid_cursor (400), not_found (404, no programme for this owner).
GET /v1/ambassadors/{id}/ledger
One member's credit history, newest first, with their running balance: what each sale, claim, quest or hand grant added or took.
Scope read:ambassador-members. Never cached.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
limit | integer, 1 to 100 | 25 | Page size |
cursor | opaque string | none | From a previous page |
{
"member": { "id": "5f0c6a0e-2b1d-4c3e-9f8a-7b6c5d4e3f21", "first_name": "Melanie", "last_name": "Pauwels", "status": "active" },
"credits_enabled": true,
"balance": 12,
"data": [{ "id": "7a1b...", "amount": 3, "reason": "ticket_sale", "note": null, "created_at": "2026-09-20T18:00:00.000Z" }],
"pagination": { "next_cursor": null, "has_more": false, "limit": 25 }
}
The owner the crew must manage is read from the member's own programme, never from the request: another crew's member answers the same 404 as an unknown id. Errors: not_found (404, malformed id or member outside the crew's programmes), invalid_cursor (400).
GET /v1/ambassador-stats
How the programme is doing: members by status and the share of active ones who sell, the sales its codes brought in (lifetime, and between from and to when given), and the ten best codes over the same span.
Scope read:ambassador-members. Never cached.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
owner_type | promoter, venue | required | Whose programme |
owner_id | positive integer | required | The page's id |
from / to | ISO 8601 date or date-time | none | Window, at most 400 days apart, to after from |
{
"members": { "total": 12, "active": 8, "pending": 3, "suspended": 1, "selling": 3, "selling_rate": 0.375 },
"lifetime": { "orders": 20, "tickets": 41, "revenue_cents": 90000 },
"window": null,
"top_codes": []
}
Errors: validation_error (400, owner_type / owner_id, or a window that is inverted or over 400 days), not_found (404, no programme).
POST /v1/ambassador-register
Someone joins the programme from the partner's own site. Returns their code and a member session, so the storefront can show them their page immediately.
Scope write:ambassadors (opt-in). Never cached. Takes Idempotency-Key. Rate-limited to 30 registrations a minute per visitor (client_ip, sized for a venue's shared wifi), and 300 a day per programme.
| Field | Type | Required | Notes |
|---|---|---|---|
owner_type | string | yes | promoter or venue |
owner_id | number | yes | The page's id |
first_name / last_name | string | yes | At most 120 characters |
email | string | yes | Valid email; the only way back to a lost page |
phone | string | yes | A real phone number; the dedupe key for the programme |
locale | string | no | At most 8 characters |
{ "owner_type": "promoter", "owner_id": 697, "first_name": "Jane", "last_name": "Doe", "email": "[email protected]", "phone": "+32470000000" }
Response: 201 on a genuinely new member, 200 when it already existed:
{ "created": true, "member_id": "6b1d...", "code": "MELANPAU", "status": "active", "session": { "token": "amb_st_...", "expires_at": "2026-09-01T18:00:00.000Z" } }
A phone number already enrolled in this programme is refused, not signed in: whoever typed the number is not necessarily whose number it is.
Errors: validation_error (422, missing or invalid fields, or a disposable/blocked email), not_found (404, owner outside the crew's graph), conflict (409, signup_closed unless the promoter turned public sign-up on and a live storefront invite has quota, or already_registered for a phone already enrolled), rate_limited (429).
POST /v1/ambassador-login
Emails a member a one-time sign-in link that lands back on the partner's own site.
Scope write:ambassadors. Never cached. Takes Idempotency-Key. Rate-limited to 8 a minute per visitor (client_ip), and 5 an hour per email address for the actual send.
| Field | Type | Required | Notes |
|---|---|---|---|
owner_type | string | yes | promoter or venue |
owner_id | number | yes | The page's id |
email | string | yes | Where to send the link |
return_origin | string | yes | One of the origins registered on this key |
return_path | string | no | A relative path on that origin, default /ambassador |
Response 202, always the same body: { "status": "accepted" }. An unknown address, a suspended member, a closed programme and a genuine send are indistinguishable: a login form that answered differently for a known address would be a membership oracle. return_origin and return_path are re-validated in SQL, so there is no way to assemble an open redirect.
Errors: validation_error (422, bad body, or return_origin not one of the key's registered origins), rate_limited (429, per visitor or per email address).
POST /v1/ambassador-login-exchange
The partner's landing page receives ?amb_token=... and trades it, server-side, for a member session.
Scope write:ambassadors. Never cached. Takes Idempotency-Key. Rate-limited to 20 attempts a minute per visitor (client_ip).
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | The amb_lt_... value from the query string |
{ "session": { "token": "amb_st_...", "expires_at": "2026-09-01T18:00:00.000Z" }, "return_url": "https://www.example.com/ambassador" }
Single use: the token is spent by the same database update that reads it, so two taps in a mail client that prefetches links cannot both succeed.
Errors: invalid_key (401, malformed, unknown, expired or already-used token, all identical), rate_limited (429).
GET /v1/ambassador-me
The signed-in member's own page: code, sales, credits and rewards, plus XP, tier, quests and badges when the promoter turned gamification on.
Scope read:ambassadors, plus an X-Ambassador-Session header. Never cached.
{
"membership": {
"member": { "id": "0b7c9d1e-2f3a-4b5c-8d6e-7f8091a2b3c4", "first_name": "Melanie", "last_name": "Paulus", "status": "active", "code": "MELANPAU" },
"program": { "id": "5e6f7081-92a3-4b4c-95d6-e7f8091a2b3c", "name": "Insomnia Ambassadors", "description": null, "leaderboard_visible": true, "owner": { "id": 697, "type": "promoter", "name": "Insomnia", "image_url": null } },
"owner": { "id": 697, "type": "promoter", "name": "Insomnia", "image_url": null },
"stats": { "orders": 4, "tickets": 9, "revenue_cents": 27000 },
"credits": { "enabled": true, "balance": 12, "label_singular": "credit", "label_plural": "credits" },
"game": { "enabled": true, "xp_label": "XP", "lifetime_xp": 340, "tier": { "id": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091", "name": "Gold", "min_xp": 300, "color": "#f5c542" }, "next_tier": null, "badges_earned": 2, "quests_completed": 3 },
"tiers": [],
"quests": [],
"badges": [],
"xp_ledger": [],
"rewards": [],
"claims": [],
"ledger": [],
"events": [],
"leaderboard": []
},
"session": { "expires_at": "2026-09-26T20:00:00+00:00" }
}
The same member page sway.events shows the member, shortened here: game also carries the programme's own words (tier_noun, quest_noun, badge_noun) and next_tier says how much XP is left to climb (null at the top). quests, badges, tiers and xp_ledger fill in only while gamification is on, rewards, claims and ledger (the credit history) only while credits are on. events lists the events the member's code works on, with its discount, and leaderboard the ranking when the promoter publishes it, with revenue_cents on each rank only in a revenue ranking, as on sway.events.
access_token (the member's permanent credential for their page on sway.events) and their phone number never appear here, whatever the row underneath holds.
Errors: invalid_key (401, the header is missing or malformed, or the session is expired or invalid).
POST /v1/ambassador-profile
The signed-in member corrects their own name, email or language.
Scope write:ambassadors, plus session. Never cached. Takes Idempotency-Key. Rate-limited to 20 a minute per visitor (client_ip).
| Field | Type | Required | Notes |
|---|---|---|---|
first_name / last_name / email / locale | string | no | Any subset; at least one is required |
The phone number cannot be changed here (it is the dedupe key: changing it would be a merge, not an edit), nor can status, code, credits or XP: the code does not follow a rename.
Errors: invalid_key (401, session), validation_error (422, bad email, no field supplied, or a disposable/blocked email), rate_limited (429).
POST /v1/ambassador-claim
The signed-in member spends credits on a reward. The caller never names a price: cost, balance, stock, the per-member limit and the tier gate are all read and enforced from the ledger under a row lock.
Scope write:ambassadors, plus session. Never cached. Takes Idempotency-Key. Rate-limited to 20 a minute per visitor (client_ip).
| Field | Type | Required | Notes |
|---|---|---|---|
reward_id | string | yes | A UUID |
answer | string | no | At most 500 characters, when the reward asks a question |
Response 201 on success, carrying claim_id and status.
Errors: invalid_key (401, no session), not_found (404, unknown reward), conflict (409, inactive, not_started, ended, out_of_stock, max_per_member, insufficient_credits), validation_error (403, member_not_active or tier_locked; 422, answer_required or a malformed body).
member_not_active and tier_locked, answer 403 with the code validation_error: read the reason field to tell them apart.POST /v1/ambassador-email-preference
The signed-in member turns programme emails off, or back on.
Scope write:ambassadors, plus session. Never cached. Takes Idempotency-Key. Rate-limited to 20 a minute per visitor (client_ip).
| Field | Type | Required | Notes |
|---|---|---|---|
opt_out | boolean | yes |
A toggle, not the one-way opt-out an email footer performs: the portal is the one place a member can prove who they are and change their mind back.
Errors: invalid_key (401, session), validation_error (422, opt_out missing or not a boolean).
POST /v1/ambassador-logout
Revokes the session named in X-Ambassador-Session.
Scope write:ambassadors. Never cached. Takes Idempotency-Key.
Response: always 200 { "ok": true }, whether or not the session existed, and whether or not the header was even sent: signing out is not a place to learn anything.
Errors: none beyond authentication.
GET /v1/ambassador-code
Resolves an ?amb=CODE on a ticket page: is this code live on this event, what is it worth, and whose is it. A preview, never a price: the terms are re-read inside the checkout from the same opening.
Scope read:ambassadors. Cached 30 s. Rate-limited to 60 lookups a minute per key.
| Parameter | Values | Default | Meaning |
|---|---|---|---|
event_id | positive integer | required | The event the code is checked against |
code | text, at most 64 characters | required | The code as typed |
{ "code": "MELANPAU", "discount_id": "f4c8b1a2-...", "ambassador_first_name": "Melanie", "percentage": 15, "amount": null, "min_order_amount": 0 }
discount_id is what a storefront passes to POST /v1/checkout-sessions as discount_id: that route refuses a raw coupon code outright. A code that does not exist, is not on this event, belongs to a suspended member, or belongs to another tenant, all answer the same 404: anything more specific would make this an enumeration oracle across every promoter on the platform.
Errors: validation_error (422, missing or malformed event_id / code), not_found (404), rate_limited (429).
MCP and webhooks
Two more ways to reach the same data, each documented on its own page:
POST /v1/mcpis the Sway MCP server: the read routes above, exposed as read-only tools for an AI assistant over JSON-RPC (streamable HTTP, stateless), behind a key holding themcp:accessscope. See The Sway MCP server.- Webhooks, configured in Crew admin → API → Webhooks, push events (orders paid and refunded, published and updated events, booking requests, ambassador sales, form answers, payouts) to a partner's own server as they happen, signed the Standard Webhooks way. See Webhooks.