Authentication

API keys, scopes, rate limits, caching, errors and idempotency in the Sway Public API.

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 keyPublishable key
Prefixsway_sk_sway_pk_
Runs inYour serverA visitor's browser
ScopesAnyThe public reads only: read:profile, read:artists, read:promoters, read:events, read:venues, read:content, read:shop
Where it answersAnywhereOnly on the websites listed on the key (checked against the browser's Origin)
Sent asAuthorization: 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.

A secret key sent as ?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).

ActionWhat happens
CreateName, 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
EditChanges the scopes and address lists; applies at once
SuspendThe kill switch. Every request answers 403 key_suspended until someone clicks Resume. Nothing is deleted
RotatePOST /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
RevokeFinal. 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
ExpireAfter 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.

ScopeOpensDefault
read:profileThe crew's own profile: /v1/crew, /v1/crew/pagesYes
read:artistsThe roster, each artist, their events, calendars and press kitsYes
read:promotersThe promoters you manage, their events, venues and artistsYes
read:eventsEvents, line-ups, days, timetables, ticket tiers, availability, fees, gallery, presalesYes
read:venuesVenues, their events and capacityYes
read:contentNews, partners, forms and press kitsYes
read:ambassadorsThe ambassador programme's public page, leaderboard, rewards and quests, and a signed-in member's own pageNo
read:ambassador-membersThe members, their ledgers and the programme statistics. Personal data: crew owner onlyNo
read:exportThe whole catalogue as NDJSON downloadsNo
read:shopThe merch of the pages you manage: catalogue, product, what an event sells; and pricing a cartNo
write:bookingsSend booking requests (POST /v1/booking-requests)No
write:newsletterAdd newsletter sign-ups to a promoter's CRM, and take an address offNo
write:formsSubmit answers to your public formsNo
write:checkoutOpen paid ticket checkouts, and check coupons and access codesNo
write:checkout:freeAlso issue free and 100%-off ticket orders. Crew owner onlyNo
write:shop:checkoutOpen merch checkouts for a page you manageNo
write:ambassadorsJoin, sign in and out, claim a reward, edit one's own detailsNo
write:keysLet the key rotate itself (POST /v1/keys/rotate)No
mcp:accessUse the key with the MCP serverNo

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 Modified costs 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.
PlanWork per minuteWork per day
Studio12020,000
Roster300100,000

What one uncached request costs:

RequestWork units
A detail (/v1/events/{id})1
A list page1 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:

AllowanceValue
Requests, cached or not2,400 a minute on Studio, 6,000 on Roster (or 20 times the key's work per minute, whichever is higher)
Response data5 GB a day on Studio, 20 GB on Roster
All the crew's keys together600 work a minute and 120,000 a day on Studio; 1,500 and 500,000 on Roster. As many writes again
Writes (POST), checkouts apartAs many as the key's work per minute, never under 60
Checkout sessions600 a minute, for the key and for all the crew's keys together, apart from the other writes
Coupon and access code checks300 a minute, apart from the writes
MCP messages120 a minute
Publishable key, per visitor address60 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.

With 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"
}
codeStatusMeaning
invalid_key401The key is malformed, unknown, revoked or expired
key_in_query400A secret key was sent as ?key=. Rotate it
key_suspended403The key is suspended; a crew admin can resume it
subscription_required403The crew's plan no longer includes the API
missing_scope403The key lacks the endpoint's scope, or a publishable key called an endpoint that needs a secret key
origin_not_allowed403A publishable key was used from a website not on its list, or a browser called the MCP server
not_found404The resource does not exist or is outside your crew's pages
conflict409The same Idempotency-Key is still being processed, or the action was already done (a key rotates once)
validation_error400 / 422A query parameter (400) or the request body (422) is invalid
invalid_cursor400The pagination cursor is malformed or stale
idempotency_mismatch422This Idempotency-Key was used with another body
password_required401A protected press kit needs Presskit-Password
rate_limited429A limit was reached; see Retry-After
internal_error500Something failed on our side; safe to retry
upstream_error502A 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.