Authentication
Every request carries an API key as a Bearer token:
curl https://www.sway.events/api/v1/artists \
-H "Authorization: Bearer sway_sk_YOUR_KEY"
The key identifies your crew. There is no tenant parameter anywhere in the API: what a key can see is decided by the crew it belongs to, never by the request.
Two kinds of key
| Secret key | Publishable key | |
|---|---|---|
| Prefix | sway_sk_ | sway_pk_ |
| Runs in | Your server | A visitor's browser |
| Scopes | Any | The public reads only: read:profile, read:artists, read:promoters, read:events, read:venues, read:content, read:shop |
| Where it answers | Anywhere | Only on the websites listed on the key (checked against the browser's Origin) |
| Sent as | Authorization: Bearer ... | Authorization: Bearer ..., or ?key= when a header is impractical |
Use a secret key for everything your server does, and for anything that writes, sells tickets or touches members. Never ship it to a browser or an app: anyone who can read your pages can read the key.
Use a publishable key when a browser must call the API directly, for example a static site with no server. It only reads what Sway already shows publicly, only answers on its own websites, and gets a smaller allowance per visitor address on top of the key's own.
?key= is refused with 400 key_in_query, because URLs end up in logs and browser histories. If that happens, the key must be treated as leaked: rotate it.Key lifecycle
Keys are managed in Crew admin → API (Studio and Roster plans, Manage API permission).
| Action | What happens |
|---|---|
| Create | Name, type, scopes, websites or storefront addresses, and an expiry (never, 30, 90 or 365 days). The full key is shown once; afterwards only its start and last four characters are shown |
| Edit | Changes the scopes and address lists; applies at once |
| Suspend | The kill switch. Every request answers 403 key_suspended until someone clicks Resume. Nothing is deleted |
| Rotate | POST /v1/keys/rotate with a key holding write:keys returns its replacement: same name, type, scopes and limits. The old key keeps working for grace_hours (1 to 168, default 24), then expires. A key rotates once, at most once an hour, and the crew owner is emailed each time |
| Revoke | Final. Requests answer 401 invalid_key. When the key was rotated, Also revoke the keys that replaced it revokes the whole chain: use it when the key leaked, since whoever holds a leaked key with write:keys could have rotated it |
| Expire | After expires_at, requests answer 401 invalid_key |
A revoked, expired or unknown key all answer the same 401 invalid_key: the API never tells a caller whether a key it does not know ever existed. The Activity panel on each key shows what it has used of its limits and its latest calls.
If the crew's subscription lapses, every key answers 403 subscription_required until it is active again.
Scopes
Scopes are chosen when the key is created and can be edited later. A request without the scope its endpoint needs answers 403 missing_scope.
| Scope | Opens | Default |
|---|---|---|
read:profile | The crew's own profile: /v1/crew, /v1/crew/pages | Yes |
read:artists | The roster, each artist, their events, calendars and press kits | Yes |
read:promoters | The promoters you manage, their events, venues and artists | Yes |
read:events | Events, line-ups, days, timetables, ticket tiers, availability, fees, gallery, presales | Yes |
read:venues | Venues, their events and capacity | Yes |
read:content | News, partners, forms and press kits | Yes |
read:ambassadors | The ambassador programme's public page, leaderboard, rewards and quests, and a signed-in member's own page | No |
read:ambassador-members | The members, their ledgers and the programme statistics. Personal data: crew owner only | No |
read:export | The whole catalogue as NDJSON downloads | No |
read:shop | The merch of the pages you manage: catalogue, product, what an event sells; and pricing a cart | No |
write:bookings | Send booking requests (POST /v1/booking-requests) | No |
write:newsletter | Add newsletter sign-ups to a promoter's CRM, and take an address off | No |
write:forms | Submit answers to your public forms | No |
write:checkout | Open paid ticket checkouts, and check coupons and access codes | No |
write:checkout:free | Also issue free and 100%-off ticket orders. Crew owner only | No |
write:shop:checkout | Open merch checkouts for a page you manage | No |
write:ambassadors | Join, sign in and out, claim a reward, edit one's own details | No |
write:keys | Let the key rotate itself (POST /v1/keys/rotate) | No |
mcp:access | Use the key with the MCP server | No |
GET /v1/me, /v1/usage, /v1/audit-log and /v1/genres need no scope: any valid key may call them.
The "crew owner only" scopes can only be granted by the crew owner; a member with the Manage API permission can remove them but not add them. GET /v1/scopes returns this list as JSON.
Websites and storefront addresses
Two address lists live on a key, both set in Crew admin → API:
- Websites (publishable keys): the only browser origins the key answers. A request from another site answers
403 origin_not_allowed. - Storefront addresses (keys with an ambassador scope): where Sway may send a member back after an emailed sign-in link. Your site passes the return address with the request; Sway accepts it only if its origin is on the list, so the link can never be pointed somewhere else. A key with an ambassador scope and an empty list still serves pages, but can never sign a member back in.
Only https origins are accepted, plus http on localhost for development. Paths are dropped: only the origin is stored and compared.
Rate limits
The API is built so that a busy partner site keeps showing its data. Two ideas carry that:
- A cached answer is free. Reads are cached per crew. Repeating a request the cache already holds costs nothing, and a
304 Not Modifiedcosts nothing. - Work, not requests, is counted. Only a request that has to read the database spends the key's work budget, and it spends in proportion to what it reads.
| Plan | Work per minute | Work per day |
|---|---|---|
| Studio | 120 | 20,000 |
| Roster | 300 | 100,000 |
What one uncached request costs:
| Request | Work units |
|---|---|
A detail (/v1/events/{id}) | 1 |
| A list page | 1 per started block of 25 items (limit=100 costs 4) |
Each expand value | +1 |
A batch lookup (ids=) | 1 per started block of 10 ids |
When the budget runs out, a read the cache holds is still answered, from the last copy, with Cache-Status: sway; hit; ...; detail=rate-limited. Only a read with no copy at all answers 429 rate_limited, with Retry-After.
Other allowances, per key unless stated:
| Allowance | Value |
|---|---|
| Requests, cached or not | 2,400 a minute on Studio, 6,000 on Roster (or 20 times the key's work per minute, whichever is higher) |
| Response data | 5 GB a day on Studio, 20 GB on Roster |
| All the crew's keys together | 600 work a minute and 120,000 a day on Studio; 1,500 and 500,000 on Roster. As many writes again |
Writes (POST), checkouts apart | As many as the key's work per minute, never under 60 |
| Checkout sessions | 600 a minute, for the key and for all the crew's keys together, apart from the other writes |
| Coupon and access code checks | 300 a minute, apart from the writes |
| MCP messages | 120 a minute |
| Publishable key, per visitor address | 60 work and 600 requests a minute |
Every authenticated answer reports the key's work budget:
RateLimit-Policy: "burst";q=120;w=60, "daily";q=20000;w=86400
RateLimit: "burst";r=118;t=42, "daily";r=19873;t=51230
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1759525489
RateLimit-Policy and RateLimit follow the IETF draft; the X-RateLimit-* headers show the tighter of the two windows and stay for existing clients. On a 429, wait Retry-After seconds; a key that keeps hitting the limit gets longer waits. A key that sweeps through ids in an unusual pattern is limited to cached answers for a while.
If-None-Match and the cache, a partner site serving thousands of visitors a day stays far below these figures. The integration guide shows the pattern. If you need more, contact us: limits are set per key.Caching
Each cacheable read carries its lifetime and an ETag:
Cache-Control: private, max-age=60, stale-while-revalidate=120, stale-if-error=86400
ETag: "4f9a1c7e2b8d5f3a"
Cache-Status: sway; hit; ttl=42
Send the ETag back as If-None-Match: when nothing changed, the answer is 304 Not Modified, with no body and no cost. Cache-Status says where an answer came from: hit (the cache), fwd=miss (read fresh), fwd=stale (refreshed after its lifetime ran out). Lifetimes range from 30 seconds (ticket tiers, availability) to an hour (genres); the endpoint reference gives each one. Answers that carry key-level or member-level data are never cached (Cache-Control: private, no-store).
To skip Sway's copy, send Cache-Control: no-cache with a secret key: the request is read fresh (Cache-Status: sway; fwd=request; stored) and costs work like any uncached read. That is the request to make when a webhook says something changed, since the copy may predate the change. When your work budget is spent, you get the copy anyway, with detail=rate-limited. A publishable key's Cache-Control is ignored.
Errors
Every error is an RFC 9457 application/problem+json body with a stable code to branch on, and the request's id:
{
"type": "https://www.sway.events/en/docs/api/errors#missing_scope",
"title": "Missing Scope",
"status": 403,
"detail": "This API key does not have the required scope.",
"instance": "/api/v1/events/4521",
"code": "missing_scope",
"request_id": "0b7c2e4a-9f1d-4a3b-8c6e-5d2f1a9b7e3c"
}
code | Status | Meaning |
|---|---|---|
invalid_key | 401 | The key is malformed, unknown, revoked or expired |
key_in_query | 400 | A secret key was sent as ?key=. Rotate it |
key_suspended | 403 | The key is suspended; a crew admin can resume it |
subscription_required | 403 | The crew's plan no longer includes the API |
missing_scope | 403 | The key lacks the endpoint's scope, or a publishable key called an endpoint that needs a secret key |
origin_not_allowed | 403 | A publishable key was used from a website not on its list, or a browser called the MCP server |
not_found | 404 | The resource does not exist or is outside your crew's pages |
conflict | 409 | The same Idempotency-Key is still being processed, or the action was already done (a key rotates once) |
validation_error | 400 / 422 | A query parameter (400) or the request body (422) is invalid |
invalid_cursor | 400 | The pagination cursor is malformed or stale |
idempotency_mismatch | 422 | This Idempotency-Key was used with another body |
password_required | 401 | A protected press kit needs Presskit-Password |
rate_limited | 429 | A limit was reached; see Retry-After |
internal_error | 500 | Something failed on our side; safe to retry |
upstream_error | 502 | A service Sway depends on (payments, email) failed; safe to retry |
validation_error also carries an errors array of { "param", "message" }. Endpoints add codes of their own where they need them (a sold-out tier, a code that does not apply); the endpoint reference lists them.
404 not_found stands for both "missing" and "not yours": the API never confirms that data outside your crew exists.
Every answer, success or error, carries X-Request-Id. Quote it when you contact us: it finds the request in our logs. You may send your own X-Request-Id (8 to 128 letters, digits, ., _, : or -) to tie our logs to yours; anything else is replaced by a fresh id.
Idempotency
A network retry must never book, sign up or refund twice. Send an Idempotency-Key header on a POST, a unique value per operation (a UUID is ideal, 1 to 255 visible ASCII characters):
curl -X POST https://www.sway.events/api/v1/booking-requests \
-H "Authorization: Bearer sway_sk_YOUR_KEY" \
-H "Idempotency-Key: 5b8f3c1e-2a7d-4e9b-8c6f-1d3a5e7b9c2f" \
-H "Content-Type: application/json" \
-d '{ "artist_ids": [501], "event_name": "Warehouse Series #4", "contact_email": "[email protected]" }'
- The same key with the same body within 24 hours gets the first answer back, without doing the work again.
- The same key with another body answers
422 idempotency_mismatch. - A retry that arrives while the first request is still running answers
409 conflict: wait and retry.
It works on every POST except opening a checkout, which guards against duplicates on its own, and the coupon and access code checks, which change nothing. An answer that carries a secret (a rotated key) is never stored for a replay.