Connect BackstagePass to your tools
Pick how you’ll connect.
No code
Zapier, Make, n8n or Pabbly. Three steps.
Start →API and webhooks
A five-minute quickstart, then the reference.
Start →Hand it to your AI
Claude, ChatGPT, Cursor, OpenClaw or n8n AI.
Start →Give this to your AI agent
Paste it into your agent. It will ask you for your key.
Connect to my BackstagePass account.
1. Read https://backstagepass.com/developers/llms.txt, then https://backstagepass.com/developers/llms-full.txt.
2. Requests are described in https://backstagepass.com/developers/openapi.json. Webhook bodies are described in https://backstagepass.com/developers/webhooks.schema.json.
3. Base URL: https://backstagepass.com. Send my API key in the header Authorization: Bearer <key>. Ask me for the key. Never guess it.
4. Start with GET /api/v1/me and show me the handle it returns. The People calls (POST /api/v1/people and its revoke, block and unblock) change my list: ask me before each one.
5. On 401, ask me for a new key. On 429, wait 60 seconds, then try again.
6. You cannot add a webhook yourself. Tell me the exact URL and moments to add in Studio > Automations > APIs & Webhooks, then ask me for that row's signing secret.
7. Follow the numbered steps at https://backstagepass.com/help/how/automations#agent-steps.Contents
Connect in 3 steps
No code. Your tool gives you a web address. BackstagePass sends each moment there.
Copy your tool’s webhook address
Zapier
- In Zapier, make a new Zap.
- Trigger: search Webhooks by Zapier. Event: Catch Hook. Press Continue.
- Copy the webhook URL Zapier shows.
- Once a real moment has arrived, press Test trigger to see its fields.
If the Zap is off, Zapier answers 404 and BackstagePass counts a failed delivery.
Make
- In Make, create a new scenario.
- Add the Webhooks app, module Custom webhook.
- Press Add, give it a name, press Save.
- Copy the address Make shows. Make waits for the first request to learn the fields.
n8n
- Add a Webhook node.
- Set HTTP Method to POST. It starts on GET.
- Copy the Production URL, not the Test URL.
- Turn the workflow on. Until then, nothing reaches the Production URL.
- Each arrival shows under Executions, with its fields.
Pabbly
- In Pabbly Connect, make a new workflow.
- For the trigger app, choose Webhook by Pabbly.
- Copy the webhook URL it shows.
Paste it in BackstagePass
- Open Studio → Automations → APIs & Webhooks.
- On Webhooks, paste the URL.
- Every moment starts ticked. Untick the ones you don't need.
- Press Add the webhook.
Pick what happens
In your tool, add the step that does the work: a contact, a post, a row. Use a recipe below.
What can happen
Each tick box in Studio, in plain words.
| Tick box in Studio | What happened | Carries a fan | Name in your tool |
|---|---|---|---|
| Follow confirmation | Someone joined your list | Yes | welcome |
| Live scheduled | You booked a live | No | seat_reserved |
| Going live | You went live | No | going_live |
| 15 minutes before | Your live starts in 15 minutes | No | reminder_15m |
| 60 minutes before | Your live starts in an hour | No | reminder_60m |
| 1 day before | Your live is a day away | No | reminder_1d |
| Live rescheduled | You moved a live | No | live_rescheduled |
| Live cancelled | You cancelled a live | No | live_canceled |
| Seat reserved | A fan RSVP'd to a live | Yes | counted_in |
| Encore published | Your replay is ready | No | encore_published |
| New pass created | You put a Pass on sale | No | new_pass |
| Pass expired | A fan's Pass ended | Yes | win_back |
| Joined backstage | A fan bought a Pass | Yes | joined_backstage |
| Bought a ticket | A fan got a ticket | Yes | ticket_bought |
| Became inactive | A fan went quiet for 60 days | Yes | gone_quiet |
| New transaction | A fan paid you | Yes | new_transaction |
Recipes
Every new Pass holder into my CRM
- Tick
- Joined backstage only.
- Then
- Your CRM's create or update contact step. Use the fan's email, name and phone.
The moment does not say which Pass yet.
Field names
| Field | Use it for |
|---|---|
fan.email | |
fan.name | Name |
fan.phone | Phone |
fan.id | Match on this to avoid duplicates |
Post Going Live in my Discord or Slack
- Tick
- Going live only.
- Then
- Discord or Slack send channel message. Put the live's title and its room link in the message.
Field names
| Field | Use it for |
|---|---|
stream.title | The live's title |
stream.room_url | The link fans tap |
Log every transaction to a Google Sheet
- Tick
- New transaction only.
- Then
- Google Sheets add a row. One column per field below.
Field names
| Field | Use it for |
|---|---|
transaction.occurred_at | When |
transaction.kind | subscription, ticket, tip or drop |
transaction.amount_cents | Amount, in cents |
transaction.amount_after_fees_cents | After fees, in cents. Empty when not reported |
fan.name | Who |
fan.email | Their email |
Send my Welcome through my email tool
- Tick
- Follow confirmation only.
- Then
- Your email tool's add subscriber step, with the fan's email. Skip it when the email is empty.
Turn off Follow confirmation in Automations so fans don't get two welcomes. The webhook keeps sending.
Field names
| Field | Use it for |
|---|---|
fan.email | |
fan.name | Name |
Export my audience to a spreadsheet
- Once
- Studio → Followers → Export. Pick who to include. The file opens in Google Sheets or Excel.
- Every week
- Needs your API key and a scheduled step in your tool.
How, with the API key
your tool's scheduled HTTP request step (Zapier: Webhooks by Zapier GET; Make: HTTP → Make a request). Address https://backstagepass.com/api/v1/audience/export, header Authorization: Bearer <your API key>. Then add the rows to your sheet.
curl https://backstagepass.com/api/v1/audience/export \
-H "Authorization: Bearer $BSP_API_KEY" \
-o audience.csvAdd and remove people from your tools
These go the other way: your tool calls BackstagePass. They need your API key (Studio → Automations → APIs & Webhooks → API access → Generate new key) and, to add to a Pass, its Pass ID (Studio → Passes, the Copy button beside Pass ID).
The POST step in your tool
Zapier
- Add an action: search Webhooks by Zapier. Event: POST. Press Continue.
- URL: the address above.
- Payload Type: Json.
- Data: one line per field in the body below. Map the email from your trigger.
- Headers:
Authorization=Bearer <your API key>. - Press Test step.
201or200means it worked.
Make
- Add the HTTP app, module Make a request.
- URL: the address above. Method: POST.
- Headers: name
Authorization, valueBearer <your API key>. - Body: JSON (
application/json). Paste the body below and map the email from your trigger. - Press Run once.
201or200means it worked.
n8n
- Add an HTTP Request node. Method: POST. URL: the address above.
- Turn on Send Headers:
Authorization=Bearer <your API key>. - Turn on Send Body. Body Content Type: JSON. Specify Body: Using JSON. Paste the body below and map the email.
- Run the step.
201or200means it worked.
Add a buyer from my website or newsletter to a Pass
- When
- Your shop, form or newsletter tool's own trigger, such as new order or new subscriber.
- Then
- An HTTP POST to
https://backstagepass.com/api/v1/peoplewith the buyer's email, your Pass ID and"attested": true. - They get
- They show on Followers as Invited, with the Pass waiting for them. It is theirs when they sign in with that email. No email goes from you before that, except the welcome letter when
welcomeistrue.
Body
{
"email": "<the buyer's email>",
"pass_id": "pass_3f9a0c1e7b2d4a68",
"welcome": true,
"attested": true
}attested: true is your word that they opted in to hear from you by email.
For a Pass that ends, add "expires_at": "2026-12-31T23:59:00Z".
Sending the same email again is safe: it is still one person.
Remove someone
- When
- Your tool's own refund, cancel or unsubscribe trigger.
- Then
- An HTTP POST to
https://backstagepass.com/api/v1/people/revokewith their email. Addpass_idto take one Pass only. - One Pass
- A grant ends now. A paid membership stops renewing and runs to the end of its paid term.
- Everything
- The Followers tab's Revoke. Someone still Invited has the invite withdrawn.
- For good
- POST
https://backstagepass.com/api/v1/people/blockwith their email. Only for someone who has signed in.
Body
{
"email": "<their email>",
"pass_id": "pass_3f9a0c1e7b2d4a68"
}Nothing is refunded. Refunds are made one charge at a time on Transactions.
Good to know
- Your tool does not check our signature. Keep the webhook URL private, like a password.
- Send a ping proves the link. It carries no fan, so your tool learns the fan fields from the first real moment.
- A real moment is real: booking a live tells your followers. A follow from a second account is a safe one to start with.
- A webhook sends even when that moment's notification is off in Automations. Became inactive is the one exception.
- Moments about one fan carry their name, email and phone. Email or phone can be empty.
- Money is in cents.
2700is 27.00. - 8 failed deliveries in a row pause the webhook. Fix your tool, press Send a ping, then Turn back on.
Quickstart
Prove the key, then prove a signed webhook lands. About five minutes.
Get a key
Studio → Automations → APIs & Webhooks → API access → Generate new key. Account owner only.
Call the API
export BSP_API_KEY='paste-your-key-here' curl https://backstagepass.com/api/v1/me \ -H "Authorization: Bearer $BSP_API_KEY"Expect
200:{ "handle": "maya", "display_name": "Maya Rivers", "plan": "free" }401 {"error":"unauthorized"}means the key is wrong, replaced or disabled.Catch a signed webhook
- Open webhook.site and copy Your unique URL.
- Studio → Automations → APIs & Webhooks → Webhooks: paste it, press Add the webhook. Copy the row's signing secret.
- Press Send a ping on the row.
- webhook.site shows one
POSTwithX-Backstagepass-Event: ping,X-Backstagepass-Signatureand this body:
{ "event": "ping", "creator": { "handle": "maya" }, "occurred_at": "2026-10-24T01:00:03Z" }Check the signature
Paste the raw body, header and secret into a verifier below. Then build your receiver from the reference.
Key, auth, errors
- Who can
- The account owner only. Teammates see the card but not the key.
- Format
- 40 letters and digits. One key per account.
- Replace
- Press Generate new key again. The old key stops working at once.
- Disable
- Press Disable key. Every call with it gets
401at once. - Scope
- Your account's data only. The People calls are the only writes.
- Base URL
https://backstagepass.com- Auth header
Authorization: Bearer <api-key>- Methods
GET, andPOSTfor the People calls. APOSTbody is JSON (Content-Type: application/json).- Responses
- The object itself, no envelope.
/api/v1/audience/exportreturns CSV. Times are ISO 8601, UTC. - Rate limit
- 120 requests per minute per key. Over that,
429until the minute passes. No rate-limit headers. - Version
- In the path:
/api/v1. No version header. This is the only version.
| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
404 | {"error":"not found"} | People calls: the email is not on your list. |
422 | {"error":"<plain words>"} | People calls: the request cannot be done as sent. The sentence says why. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
Endpoints
GET/api/v1/meYour creator profile
No parameters.
curl https://backstagepass.com/api/v1/me \
-H "Authorization: Bearer $BSP_API_KEY"Example response
{
"handle": "maya",
"display_name": "Maya Rivers",
"plan": "free"
}| Field | Type | Meaning |
|---|---|---|
handle | string | Your handle: backstagepass.com/<handle>. |
display_name | string | Your name as fans see it. |
plan | string | Internal plan identifier. |
| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
GET/api/v1/audienceYour audience, as counts
Counts only, no people. The reachable numbers are the same ones the Followers page and the composer show.
No parameters.
curl https://backstagepass.com/api/v1/audience \
-H "Authorization: Bearer $BSP_API_KEY"Example response
{
"fans": 1240,
"push_reachable": 610,
"email_reachable": 980,
"imported_awaiting_first_sign_in": 75,
"text_reachable": 0,
"imported": 300,
"imported_claimed": 120,
"generated_at": "2026-10-27T18:04:05Z"
}| Field | Type | Meaning |
|---|---|---|
fans | integer | People who follow you. |
push_reachable | integer | Followers a push notification can reach now. |
email_reachable | integer | Followers an email can reach now. Not imported contacts who have not signed in. |
imported_awaiting_first_sign_in | integer | Imported contacts eligible for email who have not signed in yet. |
text_reachable | integer | Followers a text message can reach. |
imported | integer | Contacts you imported. |
imported_claimed | integer | Imported contacts who have since signed in. |
generated_at | string | When this answer was computed (UTC). |
| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
GET/api/v1/audience/exportEvery person, as a CSV file
One row per person: every follower, then imported contacts who have not signed in. The same columns as Export on Followers.
No parameters.
curl https://backstagepass.com/api/v1/audience/export \
-H "Authorization: Bearer $BSP_API_KEY" \
-o audience.csv- Content-Type
text/csv; charset=utf-8- Encoding
- UTF-8, starting with a byte order mark (BOM). Strip it if your parser does not.
- Transfer
- Streamed. No
Content-Length, noContent-Disposition.
Columns, in order
name, email, phone, active_access, expired_access, active_names, expired_names, lifetime_spend, spend_passes, spend_tickets, spend_drops, eligible_lives, showed_up, showed_up_%, encores_started, encore_started_%, total_watch_time, live_minutes, encore_minutes, reachable_push, reachable_email, reachable_sms, sms_consent, email_sent, email_opened, email_open_rate, push_sent, push_delivered, push_open_rate, last_attended, lives_ago, last_sign_in, joined, source, blocked, tags, notes
| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
GET/api/v1/streamsYour ended lives and their notifications
Your 50 most recently ended lives, newest first, each with the notifications sent for it. A live that was taken down is left out. No fan appears in this payload. No pagination.
No parameters.
curl https://backstagepass.com/api/v1/streams \
-H "Authorization: Bearer $BSP_API_KEY"Example response
{
"streams": [
{
"title": "Friday soundcheck",
"slug": "friday-soundcheck",
"started_at": "2026-10-24T01:00:00Z",
"ended_at": "2026-10-24T02:10:00Z",
"peak_in_room": 99,
"hearts": 412,
"cut_retention": {
"before": 90,
"after": 72,
"pct": 80
},
"broadcasts": [
{
"trigger": "go_live",
"sent_at": "2026-10-24T01:00:12Z",
"receipt": {
"push": {
"sent": 1,
"delivered": 0,
"opened": 0,
"no_transport": 0,
"suppressed_quiet_hours": 0,
"suppressed_never_activated": 0,
"suppressed_no_consent": 0,
"suppressed_muted": 0,
"suppressed_signal_tier": 0,
"suppressed_digest": 0,
"suppressed_plan_cap": 0,
"suppressed_announcement_type": 0,
"suppressed_frequency_cap": 0,
"suppressed_push_primary": 0,
"failed": 0
}
},
"walked_in_10m": {
"push": 1
}
}
]
}
],
"generated_at": "2026-10-27T18:04:05Z"
}| Field | Type | Meaning |
|---|---|---|
streams[].title | string | The live's title. |
streams[].slug | string | The live's page: backstagepass.com/<handle>/s/<slug>. |
streams[].started_at / ended_at | string|null | UTC. |
streams[].peak_in_room | integer | Most people in the room at once. |
streams[].hearts | integer | Reactions sent during the live. |
streams[].cut_retention | object|null | Room count at the cut (before), 10 to 14 minutes later (after), and after/before as a rounded percent (pct). null if the live was never cut or there are no samples. |
streams[].broadcasts[].trigger | string | What sent the notification, e.g. go_live, stream_reminder, went_backstage. |
streams[].broadcasts[].sent_at | string | UTC. |
streams[].broadcasts[].receipt | object | Counts per channel (push, email, sms, whatsapp). A channel with all-zero counts is left out. |
streams[].broadcasts[].walked_in_10m | object | Per channel: people who entered the room within 10 minutes of the notification reaching them. Channels with nobody are left out. |
generated_at | string | When this answer was computed (UTC). |
| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
People and Passes
One person on your list, keyed by email: add, find, revoke, block and unblock. Each call does what Studio’s Followers page does for one person.
The Invited rule
- Adding someone works like Studio → Followers → Add people: they are Invited. No account and no follow are created, and the Passes are queued on their invite.
- The one email they can get before signing in is the welcome letter, only when
welcomeistrue. It goes once ever per person. - When they sign in (with a code to that address, or Apple/Google), they become a follower and every queued Pass is granted, carrying its
until. - Apart from the welcome letter, nobody gets email from you until they have signed in at least once.
- Nothing new fires: the API's add fires exactly what Studio's Add people fires.
Pass IDs
- Format
pass_plus 16 lowercase letters and digits. The integer id never appears in the API.- In Studio
- Studio → Passes: each Pass prints its Pass ID with a Copy button.
- By API
GET /api/v1/passes, below.
Bodies and errors
- Bodies
- JSON in (
Content-Type: application/json), JSON out. - Errors
- Always
{"error": "<plain words>"}. - Rate limit
- The same 120 requests a minute per key.
| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
404 | {"error":"not found"} | The email is not on your list. An account with nothing to do with you is a 404 too. |
422 | {"error":"<plain words>"} | The request cannot be done as sent. Each call below lists its sentences. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
GET/api/v1/passesEvery Pass you have, with its public id
Every Pass on your account, oldest first, with the shelf’s state word and every price it is sold at.
No parameters.
curl "https://backstagepass.com/api/v1/passes" \
-H "Authorization: Bearer $BSP_API_KEY"Example response
{
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"status": "published",
"prices": [
{
"cadence": "monthly",
"price_cents": 900
},
{
"cadence": "yearly",
"price_cents": 9000
}
]
}
],
"generated_at": "2026-10-29T12:00:00Z"
}| Field | Type | Meaning |
|---|---|---|
passes[].pass_id | string | The Pass's public id. |
passes[].name | string | The Pass's name. |
passes[].status | string | The word the Pass shelf uses: published, link_only, archived or draft. |
passes[].prices[].cadence | string | monthly, quarterly, half_yearly, yearly or lifetime. |
passes[].prices[].price_cents | integer|null | The price at that cadence, in cents. |
generated_at | string | When this answer was computed (UTC). |
| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
The person object
Every People call returns this shape. An Invited person, who has not signed in since you added them, carries three more fields and holds nothing yet.
Signed in
{
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": "1p",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "grant",
"until": "2026-12-31T23:59:00Z",
"active": true
}
]
}Invited
{
"status": "invited",
"id": null,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"follower": false,
"invited": true,
"revoked_at": null,
"blocked": false,
"access": "Invited",
"source": "API",
"passes": [],
"invited_at": "2026-10-29T12:00:00Z",
"welcome_sent": true,
"queued_passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"until": "2026-12-31T23:59:00Z"
}
]
}| Field | Meaning |
|---|---|
status | invited: added, and not signed in since. signed_in: has signed in and is on your list. withdrawn: only in the answer to the revoke that withdrew the invite. |
id | The person's account id. null for somebody who has never signed in. |
follower | true when they follow you and the follow has not been revoked. |
invited | true when status is invited. |
revoked_at | When you revoked them (the follow row stays on your list). |
blocked | true when you have blocked them. A block closes every door. |
access | The Followers page's Access chip, word for word: Blocked, Revoked, a count such as 1p · 2t, Follower, or Invited. null for a withdrawn invite. |
source | The Followers page's Source column: API, Manual, Organic, or a tracking link's label. |
passes | One entry per Pass of yours they hold or used to hold. Entries that have run out are included with active: false. |
passes[].kind | grant (you gave it) or subscription (they bought it). |
passes[].until | When it ends: the grant's expiry, or the membership's cancel date. null means no end is scheduled (a permanent grant, or a membership that renews). |
invited_at | Invited only. When you added them. |
welcome_sent | Invited only. Whether the welcome letter has gone. |
queued_passes | Invited only. The Passes waiting for them, each with the until its grant will carry when they sign in (null for no end). |
POST/api/v1/peopleAdd one person, as Add people does
Studio → Followers → Add people for one person. It adds them as Invited (see the Invited rule). It is idempotent on email.
curl https://backstagepass.com/api/v1/people \
-H "Authorization: Bearer $BSP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"name": "Ada Lovelace",
"pass_id": "pass_3f9a0c1e7b2d4a68",
"expires_at": "2026-12-31T23:59:00Z",
"welcome": true,
"attested": true
}'| Field | Required | Notes |
|---|---|---|
email | yes | The key. Case and surrounding spaces are ignored. |
attested | yes, true | The sheet's Confirmation box: you confirm this person consented to be added to your audience and to get email from you. Anything but true adds nobody. |
name | no | Up to 120 characters. If the person already has an account, their own name wins. |
phone | no | Digits with country_code ("US", "GB" or "+44"), or E.164 on its own (+14155550132). It is stored on their contact row and is never a sign-in. |
country_code | no | Reads phone, as the sheet's country picker does. |
pass_id | no | Queues this Pass for them. |
pass_ids | no | Several Passes at once, as the sheet's picker takes them. May be sent beside pass_id. |
expires_at | no | ISO 8601 time in the future. Requires a Pass. It is the end of every Pass in this request. Leave it out for a grant with no end. |
welcome | no, default false | true sends the welcome letter, once ever per person, exactly as the sheet's "Send a welcome email now" box does. |
201 when the person was not on your list, 200 when they were (Invited already, or a follower). The body is the person object.
Example response: 201, welcome on
{
"status": "invited",
"id": null,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"follower": false,
"invited": true,
"revoked_at": null,
"blocked": false,
"access": "Invited",
"source": "API",
"passes": [],
"invited_at": "2026-10-29T12:00:00Z",
"welcome_sent": true,
"queued_passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"until": "2026-12-31T23:59:00Z"
}
]
}Example response: 201, welcome off
Send "welcome": false, or leave it out. No email goes.
{
"status": "invited",
"id": null,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"follower": false,
"invited": true,
"revoked_at": null,
"blocked": false,
"access": "Invited",
"source": "API",
"passes": [],
"invited_at": "2026-10-29T12:00:00Z",
"welcome_sent": false,
"queued_passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"until": "2026-12-31T23:59:00Z"
}
]
}Example response: 200, already a follower
{
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": "1p",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "grant",
"until": "2026-12-31T23:59:00Z",
"active": true
}
]
}How it behaves
- Not on your list
- An Invited row (Source API on the Followers page, the Invited chip), with the Passes queued. Nobody is followed, and no email goes except the welcome when asked for.
- Already Invited
- The same row. New Passes are added to the queue. For a Pass already queued,
untilbecomes this request'sexpires_at(none means no end). - Already a follower
- They signed in long ago, so the Pass is granted now, as the sheet grants it. A Pass they already hold has its end set to this request's
expires_at. - An account that does not follow you
- Invited, like everybody else. Being on a list is not a relationship until they sign in.
- An invite you withdrew
- Posting the address again makes it Invited again on the same row. The welcome is still once ever, so a second letter never goes.
422 error | When |
|---|---|
Adding people needs your word first — confirm everyone here opted in to hear from you by email. Nobody was added. | attested was not true. |
email is required | |
That email address cannot be read | |
That phone number cannot be read — send it with its country code, like +14155550132 | |
No pass with that pass_id on your account | An unknown id, another creator's Pass, or an archived Pass. |
expires_at needs a pass_id | |
expires_at must be an ISO 8601 time, like 2026-12-31T23:59:00Z | |
expires_at is in the past | |
That is your own account |
| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
GET/api/v1/people?email=One person on your list, by email
200: the person object, for an Invited person or a signed-in one. 404 {"error":"not found"} when the email is neither (a withdrawn invite included).
curl "https://backstagepass.com/api/v1/people?email=ada@example.com" \
-H "Authorization: Bearer $BSP_API_KEY"Example response: signed in
{
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": "1p",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "grant",
"until": "2026-12-31T23:59:00Z",
"active": true
}
]
}Example response: Invited
{
"status": "invited",
"id": null,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"follower": false,
"invited": true,
"revoked_at": null,
"blocked": false,
"access": "Invited",
"source": "API",
"passes": [],
"invited_at": "2026-10-29T12:00:00Z",
"welcome_sent": true,
"queued_passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"until": "2026-12-31T23:59:00Z"
}
]
}| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
404 | {"error":"not found"} | The email is not on your list. An account with nothing to do with you is a 404 too. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
POST/api/v1/people/revokeRevoke one Pass, or everything
curl https://backstagepass.com/api/v1/people/revoke \
-H "Authorization: Bearer $BSP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"pass_id": "pass_3f9a0c1e7b2d4a68",
"mode": "at_period_end"
}'| Field | Required | Notes |
|---|---|---|
email | yes | |
pass_id | no | Leave it out to revoke everything. |
mode | no, default at_period_end | Only applies to a paid membership on that Pass. at_period_end stops renewal: access runs to the end of the paid term and the card is never charged again. immediate closes the door now and stops billing at period end. |
- Invited, with
pass_id - That Pass comes off their queue, with its
until. Nothing was granted yet, so nothing else changes. - Invited, without
pass_id - The invite is withdrawn. Every queued Pass goes, they leave the Invited list, and signing in later makes them nothing on your list. The answer has
status: "withdrawn", and a GET is then a 404. Studio's Followers page has no button for this; the API's withdraw is the one way to take an invite back. The record is kept, so the welcome stays once ever and an unsubscribe they made from it still holds. - Signed in, with
pass_id - A grant of that Pass ends now. A membership to that Pass is cancelled at Whop as Studio's bulk Cancel does it (
at_period_end), or revoked as the Followers Revoke does it (immediate), and the member gets the same email. Nothing is refunded. Refunds are made one charge at a time on Transactions. - Signed in, without
pass_id - This is the Followers tab's Revoke for this one person. Paid memberships are revoked through the bulk revoke path, and every grant and ticket of yours ends. The follow row stays on your list stamped Revoked, so
accessbecomesRevoked. They can re-follow or re-buy on their own; block them to stop that.
200: the person object plus what happened.
Example response: one Pass, a membership at period end
{
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": "1p",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"until": "2026-11-30T00:00:00Z",
"active": true
}
],
"revoked": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"until": "2026-11-30T00:00:00Z"
}
],
"skipped": []
}Example response: everything
{
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": false,
"invited": false,
"revoked_at": "2026-10-29T12:00:00Z",
"blocked": false,
"access": "Revoked",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"until": "2026-11-30T00:00:00Z",
"active": false
},
{
"pass_id": "pass_9b0e2f6c1a3d5e70",
"name": "Back Room",
"kind": "grant",
"until": "2026-10-29T12:00:00Z",
"active": false
}
],
"revoked": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"until": "2026-11-30T00:00:00Z"
},
{
"pass_id": "pass_9b0e2f6c1a3d5e70",
"name": "Back Room",
"kind": "grant",
"until": "2026-10-29T12:00:00Z"
},
{
"name": "Tuesday coaching call",
"kind": "ticket"
},
{
"kind": "follow"
}
],
"skipped": []
}Example response: an invite withdrawn
{
"status": "withdrawn",
"id": null,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"follower": false,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": null,
"source": "API",
"passes": [],
"revoked": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "queued"
},
{
"kind": "invite"
}
],
"skipped": []
}Example response: a lifetime seat skipped
{
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": "1p",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"until": null,
"active": true
}
],
"revoked": [],
"skipped": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"reason": "lifetime seat — nothing renews"
}
]
}revoked[].kind is queued (a Pass taken off an invite), invite (the invite withdrawn), grant, subscription, ticket or follow. skipped[].reason uses the bulk tool's own words:
skipped[].reason | When |
|---|---|
already winding down | A cancel was already scheduled. The API never cancels twice. |
lifetime seat — nothing renews | A one-time seat has no renewal to stop in at_period_end mode. Use immediate to close its door. |
not active, already canceled, already paused | |
the payment service refused: … | Whop said no, so the membership is still live there. |
holds nothing on this pass | |
already revoked, not a follower | Only when revoking a signed-in person without pass_id. |
422 error | When |
|---|---|
email is required | |
mode must be at_period_end or immediate | |
No pass with that pass_id on your account | |
One bulk act at a time — the last one hasn't finished. | A Studio bulk act is still running and this person holds a paid membership. |
| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
404 | {"error":"not found"} | The email is not on your list. An account with nothing to do with you is a 404 too. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
POST/api/v1/people/blockBlock one person
curl https://backstagepass.com/api/v1/people/block \
-H "Authorization: Bearer $BSP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com"
}'200: the person object, with blocked: true. Idempotent.
A block is the Followers tab's Block. They cannot enter a live (open ones included), watch an encore or buy. Nothing they paid for is cancelled or refunded, so unblocking brings every door back as it was. The block is recorded as made "from the API".
Example response
{
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": true,
"access": "Blocked",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "grant",
"until": "2026-12-31T23:59:00Z",
"active": true
}
]
}422 error | When |
|---|---|
not signed in yet — withdraw the invite instead | Somebody you invited who has no account yet. |
email is required |
A block belongs to an account, and Studio's Invited row has no Block either, so there is nothing to block until they sign in; withdraw the invite to stop them becoming a follower. An Invited person who already has an account (they signed in before you added them) can be blocked.
| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
404 | {"error":"not found"} | The email is not on your list. An account with nothing to do with you is a 404 too. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
POST/api/v1/people/unblockUnblock one person
curl https://backstagepass.com/api/v1/people/unblock \
-H "Authorization: Bearer $BSP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com"
}'200: the person object, with blocked: false. Idempotent. Every door comes back as it was.
Example response
{
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": "1p",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "grant",
"until": "2026-12-31T23:59:00Z",
"active": true
}
]
}422 error | When |
|---|---|
not signed in yet — withdraw the invite instead | Somebody you invited who has no account yet. |
email is required |
| Status | Body | When |
|---|---|---|
401 | {"error":"unauthorized"} | No key, unknown key, or disabled key. |
404 | {"error":"not found"} | The email is not on your list. An account with nothing to do with you is a 404 too. |
429 | {"error":"rate limited"} | Over 120 requests in a minute. |
Webhooks
- You add a URL and pick the moments it should hear.
- When a moment happens, BackstagePass sends one
POSTwith a JSON body to every URL subscribed to it. - Your server checks the signature and answers
2xx.
A webhook fires whether or not that moment’s fan notification is on in Automations. The one exception is gone_quiet, which only exists while the Became inactive automation is on. Breakout and after-party rooms never send.
Add an endpoint
- Open Studio → Automations → APIs & Webhooks.
- On Webhooks, paste the URL.
- Every moment starts ticked. Untick the ones you don't need.
- Press Add the webhook.
- URL rules
https://, a real host, no username or password in the URL, up to 2048 characters, public address (private and local networks are refused).- Moments
- At least one.
- Duplicates
- One row per URL per account.
- Secret
- 40 letters and digits, made when the endpoint is added. It cannot be changed; delete and re-add the URL for a new one.
- Row buttons
- Send a ping, Turn back on (when paused), Delete.
- Who can
- The owner and teammates with studio access.
- By API
- Not available. Endpoints are managed in Studio only.
A refused URL answers That doesn’t look like a URL we can POST to. No moment ticked answers Pick at least one moment.
Events
The picker offers these, in this order. Fan events carry the one person the moment is about.
| Wire name | Card label | Fires when | Fan | Objects | How often |
|---|---|---|---|---|---|
welcome | Follow confirmation | Someone follows you. | Fan | fan | Once per new follow. |
seat_reserved | Live scheduled | You book a live for a future time. | None | stream | Once per live. |
going_live | Going live | You go live (GO). | None | stream | Once per live. |
reminder_15m | 15 minutes before | A scheduled live is 15 minutes out. | None | stream | Once per live. |
reminder_60m | 60 minutes before | A scheduled live is one hour out. | None | stream | Once per live. |
reminder_1d | 1 day before | A scheduled live is one day out. | None | stream | Once per live. |
live_rescheduled | Live rescheduled | You move a scheduled live to a new time. | None | stream | Once per move. |
live_canceled | Live cancelled | You cancel a scheduled live. | None | stream | Once per live. |
counted_in | Seat reserved | One fan counts themselves in (RSVP) to a live. | Fan | fan, stream | Once per RSVP. |
encore_published | Encore published | A live's replay is ready to watch. | None | stream | Not deduplicated. |
new_pass | New pass created | A Pass goes on sale (listed). | None | pass | Once per Pass. |
win_back | Pass expired | A fan's Pass ends after a cancel (not a pause). | Fan | fan | Each time it happens. |
joined_backstage | Joined backstage | A fan buys a Pass. | Fan | fan | Once per purchase. |
ticket_bought | Bought a ticket | A fan gets a ticket to a live. | Fan | fan, stream | Once per ticket. |
gone_quiet | Became inactive | Daily sweep: a fan's last activity crossed 60 days. Only while the Became inactive automation is on. | Fan | fan | At most once per fan per 90 days. |
new_transaction | New transaction | A fan pays you: Pass charge, ticket, tip or drop. | Fan | fan, transaction | Once per payment. |
Two names read crossed: seat_reserved is the Live scheduled moment (you booked a live), and counted_in is the Seat reserved moment (one fan RSVP’d). Match on the wire name.
Payloads
Every body has event, occurred_at (UTC) and creator.handle. Then, depending on the event:
| Field | Type | Meaning |
|---|---|---|
fan | object | id (integer), name (string), email (string|null), phone (string|null, E.164). All four keys always present. |
stream | object | title, slug, status (scheduled|live|ended|canceled), started_at, ended_at, room_url. |
stream.replay_url | string | replay.ready only. Same address as room_url. |
pass | object | id, name. |
transaction | object | id, kind (subscription|ticket|tip|drop), amount_cents, amount_after_fees_cents (null = not reported, never zero), occurred_at. |
Key order in the body is not meaningful. A scheduled live’s start time is not in the payload.
welcome · Follow confirmation
Someone follows you. Carries the fan.
{
"fan": {
"id": 4821,
"name": "Jordan Lee",
"email": "jordan@example.com",
"phone": "+15551234567"
},
"event": "welcome",
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}seat_reserved · Live scheduled
You book a live for a future time. Carries no fan.
{
"event": "seat_reserved",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "scheduled",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": null
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}going_live · Going live
You go live (GO). Carries no fan.
{
"event": "going_live",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "live",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": "2026-10-24T01:00:00Z"
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}reminder_15m · 15 minutes before
A scheduled live is 15 minutes out. Carries no fan.
{
"event": "reminder_15m",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "scheduled",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": null
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}reminder_60m · 60 minutes before
A scheduled live is one hour out. Carries no fan.
{
"event": "reminder_60m",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "scheduled",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": null
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}reminder_1d · 1 day before
A scheduled live is one day out. Carries no fan.
{
"event": "reminder_1d",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "scheduled",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": null
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}live_rescheduled · Live rescheduled
You move a scheduled live to a new time. Carries no fan.
{
"event": "live_rescheduled",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "scheduled",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": null
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}live_canceled · Live cancelled
You cancel a scheduled live. Carries no fan.
{
"event": "live_canceled",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "canceled",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": null
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}counted_in · Seat reserved
One fan counts themselves in (RSVP) to a live. Carries the fan.
{
"fan": {
"id": 4821,
"name": "Jordan Lee",
"email": "jordan@example.com",
"phone": "+15551234567"
},
"event": "counted_in",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "scheduled",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": null
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}encore_published · Encore published
A live's replay is ready to watch. Carries no fan.
{
"event": "encore_published",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "ended",
"ended_at": "2026-10-24T02:10:00Z",
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": "2026-10-24T01:00:00Z"
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}new_pass · New pass created
A Pass goes on sale (listed). Carries no fan.
{
"pass": {
"id": 312,
"name": "Gold"
},
"event": "new_pass",
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}win_back · Pass expired
A fan's Pass ends after a cancel (not a pause). Carries the fan.
{
"fan": {
"id": 4821,
"name": "Jordan Lee",
"email": "jordan@example.com",
"phone": "+15551234567"
},
"event": "win_back",
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}joined_backstage · Joined backstage
A fan buys a Pass. Carries the fan.
{
"fan": {
"id": 4821,
"name": "Jordan Lee",
"email": "jordan@example.com",
"phone": "+15551234567"
},
"event": "joined_backstage",
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}ticket_bought · Bought a ticket
A fan gets a ticket to a live. Carries the fan.
{
"fan": {
"id": 4821,
"name": "Jordan Lee",
"email": "jordan@example.com",
"phone": "+15551234567"
},
"event": "ticket_bought",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "scheduled",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": null
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}gone_quiet · Became inactive
Daily sweep: a fan's last activity crossed 60 days. Only while the Became inactive automation is on. Carries the fan.
{
"fan": {
"id": 4821,
"name": "Jordan Lee",
"email": "jordan@example.com",
"phone": "+15551234567"
},
"event": "gone_quiet",
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}new_transaction · New transaction
A fan pays you: Pass charge, ticket, tip or drop. Carries the fan.
{
"fan": {
"id": 4821,
"name": "Jordan Lee",
"email": "jordan@example.com",
"phone": "+15551234567"
},
"event": "new_transaction",
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z",
"transaction": {
"id": 90211,
"kind": "ticket",
"occurred_at": "2026-10-24T00:58:41Z",
"amount_cents": 2700,
"amount_after_fees_cents": null
}
}stream.live · Went live (legacy)
At GO, beside going_live.
{
"event": "stream.live",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "live",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": "2026-10-24T01:00:00Z"
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}stream.cut · Went backstage (legacy)
Every social feed is cut (CUT THE SOCIALS).
{
"event": "stream.cut",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "live",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": "2026-10-24T01:00:00Z"
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}stream.restored · Feeds restored (legacy)
The social feeds come back after a cut.
{
"event": "stream.restored",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "live",
"ended_at": null,
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": "2026-10-24T01:00:00Z"
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}stream.ended · Show ended (legacy)
The live ends (END). Also fires for a live that was taken down.
{
"event": "stream.ended",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "ended",
"ended_at": "2026-10-24T02:10:00Z",
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": "2026-10-24T01:00:00Z"
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}replay.ready · Replay ready (legacy)
With encore_published.
{
"event": "replay.ready",
"stream": {
"slug": "friday-soundcheck",
"title": "Friday soundcheck",
"status": "ended",
"ended_at": "2026-10-24T02:10:00Z",
"room_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"replay_url": "https://backstagepass.com/maya/s/friday-soundcheck",
"started_at": "2026-10-24T01:00:00Z"
},
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}ping · Send a ping
Sent when you press Send a ping on the card. Every endpoint can receive it; it is not on the picker.
{
"event": "ping",
"creator": {
"handle": "maya"
},
"occurred_at": "2026-10-24T01:00:03Z"
}Verify the signature
| Header | Type | Meaning |
|---|---|---|
Content-Type | string | application/json |
X-Backstagepass-Event | string | The event wire name, same as the body's event. |
X-Backstagepass-Delivery | string | This delivery's id. The same on every retry. Use it to deduplicate. |
X-Backstagepass-Signature | string | t=<unix seconds>,v1=<hex HMAC-SHA256> |
- Read the raw request body as bytes. Do not parse and re-serialize it.
- Split the header on
,and=to gettandv1. - Compute
HMAC-SHA256(secret, t + "." + raw_body)as lowercase hex, with the signing secret from the row. - Compare it to
v1in constant time. - Recommended: reject a
tmore than a few minutes from your clock. Each retry is signed fresh, so a retry passes.
Worked example
- Secret
Xk3n9QpL2vR7tY1wZ8bC4dF6gH0jM5sA2eU7iO9q- t
1792803605- Raw body
{"event":"ping","creator":{"handle":"maya"},"occurred_at":"2026-10-24T01:00:03Z"}- Signed string
1792803605.{"event":"ping","creator":{"handle":"maya"},"occurred_at":"2026-10-24T01:00:03Z"}- Header
t=1792803605,v1=1e4bdaf501c8215c92763979b19eddeae854819028cda18cc826631964440a04
Node
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.BSP_WEBHOOK_SECRET; // from the card
const TOLERANCE_SECONDS = 300; // your choice
function verify(rawBody, header) {
const parts = Object.fromEntries(
String(header || "").split(",").map((kv) => kv.split("=", 2))
);
const t = Number(parts.t);
const v1 = parts.v1 || "";
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// Keep the raw bytes: the signature is over the body exactly as sent.
app.post("/hooks/backstagepass", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("X-Backstagepass-Signature"))) return res.sendStatus(400);
const deliveryId = req.get("X-Backstagepass-Delivery");
// if (alreadyProcessed(deliveryId)) return res.sendStatus(200);
res.sendStatus(200); // answer fast, work after
const event = JSON.parse(raw);
// queue(event, deliveryId);
});
app.listen(3000);Ruby
require "openssl"
require "json"
require "rack/utils"
SECRET = ENV.fetch("BSP_WEBHOOK_SECRET") # from the card
TOLERANCE_SECONDS = 300 # your choice
def verify(raw_body, header)
parts = header.to_s.split(",").to_h { |kv| kv.split("=", 2) }
t = parts["t"].to_i
v1 = parts["v1"].to_s
return false if t.zero? || v1.empty?
return false if (Time.now.to_i - t).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{t}.#{raw_body}")
Rack::Utils.secure_compare(expected, v1)
end
# Rails controller
class BackstagepassHooksController < ActionController::API
def create
raw = request.raw_post # the bytes as sent, never params.to_json
return head :bad_request unless verify(raw, request.headers["X-Backstagepass-Signature"])
delivery_id = request.headers["X-Backstagepass-Delivery"]
# return head :ok if already_processed?(delivery_id)
event = JSON.parse(raw)
# HandleBackstagepassEventJob.perform_later(event, delivery_id)
head :ok
end
endPython
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
SECRET = os.environ["BSP_WEBHOOK_SECRET"] # from the card
TOLERANCE_SECONDS = 300 # your choice
def verify(raw_body: bytes, header: str) -> bool:
try:
parts = dict(kv.split("=", 1) for kv in (header or "").split(","))
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET.encode(), f"{t}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
app = Flask(__name__)
@app.post("/hooks/backstagepass")
def hook():
raw = request.get_data() # the bytes as sent
if not verify(raw, request.headers.get("X-Backstagepass-Signature", "")):
return "", 400
delivery_id = request.headers.get("X-Backstagepass-Delivery")
# if already_processed(delivery_id): return "", 200
event = json.loads(raw)
# enqueue(event, delivery_id)
return "", 200Delivery and retries
Success | 2xx | Any 2xx status marks the delivery delivered. The response body is ignored. |
Timeouts | 5s / 10s | 5 seconds to connect, 10 seconds to read the response. Answer first, then do the work. |
Redirects | not followed | A 3xx counts as a failed attempt. Register the final URL. |
Retries | 4 attempts | First try, then 30 seconds, 5 minutes and 30 minutes later. Then the delivery is marked failed. |
Pause | 8 in a row | 8 deliveries in a row that fail all their attempts pause the endpoint. The card says so. |
While paused | New moments are not queued for it and are not replayed later. Send a ping still works. Turn back on resumes it. | |
Ordering | none | Deliveries are independent; a retry can arrive after a later event. Use occurred_at to order. |
Duplicates | possible | Delivery is at least once. Deduplicate on X-Backstagepass-Delivery. |
Some moments arrive twice by design, as two different events: going_live and stream.live at GO, encore_published and replay.ready when the replay is ready. Subscribe to one of each pair.
Legacy events
These five were the first webhooks. They are off the picker but still delivered to any endpoint that already holds them.
| Wire name | Label | Fires when | How often |
|---|---|---|---|
stream.live | Went live | At GO, beside going_live. | Once per live. |
stream.cut | Went backstage | Every social feed is cut (CUT THE SOCIALS). | Each cut. |
stream.restored | Feeds restored | The social feeds come back after a cut. | Each restore. |
stream.ended | Show ended | The live ends (END). Also fires for a live that was taken down. | Once per live. |
replay.ready | Replay ready | With encore_published. | Once per live. |
Testing
- Get a public HTTPS URL: a request bin such as webhook.site, or a tunnel to your machine such as
ngrok http 3000. - Add it on the Webhooks card.
- Press Send a ping. A signed
pingarrives. The row's delivered count moves when you answer 2xx. - Real events fire the real thing: booking a live notifies your followers. A follow from a second account is a harmless
welcome.
There is no sandbox account and no way to replay a past delivery.
Troubleshooting
| Symptom | Check | |
|---|---|---|
Nothing arrives | Is the moment ticked on the row? Is the row on, not paused? Is the URL public HTTPS? Lives inside a live never send. | |
Signature never matches | You are hashing parsed JSON. Hash the raw bytes, and use the secret from that endpoint's row. | |
Failed count rising | Your server answered non-2xx, redirected, or took more than 10 seconds. Answer 200 first. | |
Paused after 8 straight failures | Fix the receiver, press Send a ping to check, then Turn back on. | |
Duplicate events | Expected at least once. Deduplicate on X-Backstagepass-Delivery. | |
No gone_quiet events | It only runs while the Became inactive automation is on for at least one rail. | |
API returns 401 | The key was replaced or disabled. Generate a new one. | |
API returns 429 | Over 120 requests a minute. Wait 60 seconds. |
Embed on your website
On the same tab: one iframe that shows your room, your upcoming live or your latest encore, and stays current on its own. Copy it with Copy embed code.
<iframe src="https://backstagepass.com/embed/<handle>"
width="560" height="315" style="border:0; border-radius:12px;" allow="autoplay"></iframe>Integrate as an agent
Numbered steps. Each has the exact request, the expected answer, and what to do next.
The prompt at the top of this page points an agent here.
Steps
Read the machine files
Request
GET https://backstagepass.com/developers/llms-full.txtExpect
200, text/plain: this whole reference as markdown.Then
AlwaysUse https://backstagepass.com/developers/openapi.jsonfor request shapes andhttps://backstagepass.com/developers/webhooks.schema.jsonto validate webhook bodies.Get the API key from the human
Say to the human
Please open Studio > Automations > APIs & Webhooks, press Generate new key on API access, and paste the key here. Only the account owner can do this. If a key already exists, pressing it again replaces it.Expect
40 letters and digits.Then
Anything elseAsk again. Do not continue without a key. Prove the key
Request
GET https://backstagepass.com/api/v1/me Authorization: Bearer <key>Expect
200 { "handle": "maya", "display_name": "Maya Rivers", "plan": "free" }Then
200Tell the human the handle. Continue.401 {"error":"unauthorized"}Stop. Ask the human for a new key (step 2). Do not retry with the same key. 429 {"error":"rate limited"}Wait 60 seconds. Try once more. Anything elseStop. Report the status and body to the human. Read what you need
Request
GET https://backstagepass.com/api/v1/audience GET https://backstagepass.com/api/v1/streams GET https://backstagepass.com/api/v1/audience/exportExpect
200 JSON counts / 200 JSON {"streams": [...], "generated_at": ...} / 200 text/csvThen
CSVStrip the leading byte order mark. It holds names, emails and phones: treat it as personal data. streamsAt most the 50 newest ended lives. Older lives cannot be read. 401 / 429 / otherSame as step 3. PaceStay under 120 requests a minute. Ask the human to add your webhook
Say to the human
Please open Studio > Automations > APIs & Webhooks. On Webhooks, paste <your HTTPS URL>, leave only these moments ticked: <list>, press Add the webhook, and paste me the signing secret shown on that row.Expect
A 40-character signing secret.Then
Refused URLThe card says "That doesn't look like a URL we can POST to." Give a public https:// URL with no username or password. No secretWait. You cannot verify anything without it. Receive each POST
Request
POST <your URL> Content-Type: application/json X-Backstagepass-Event: <event> X-Backstagepass-Delivery: <id> X-Backstagepass-Signature: t=<unix>,v1=<hex> <raw JSON body>Expect
hex(HMAC-SHA256(secret, "<t>.<raw body>")) equals v1.Then
Signature mismatchAnswer 400. Do not act on the body. Stop and report to the human: the secret may be from another row. Signature matchesAnswer 200 within 10 seconds, then do the work. Delivery id seen beforeAnswer 200. Do nothing else. event is pingAnswer 200. Nothing to do. event you do not knowAnswer 200 and ignore it. Re-read the schema later. Prove it end to end
Say to the human
Please press Send a ping on that row.Expect
One POST, event "ping", body {"event":"ping","creator":{"handle":...},"occurred_at":...}, valid signature.Then
Nothing within a minuteAsk the human whether the row says paused, and whether the URL is public HTTPS. Row's failed count went upYour receiver answered non-2xx, redirected, or took over 10 seconds. Find a person
Request
GET https://backstagepass.com/api/v1/people?email=<email> Authorization: Bearer <key>Expect
200 the person objectThen
"status": "invited"Added, not signed in yet. They hold nothing; queued_passesis what they get when they sign in. Nothing to block yet."status": "signed_in"passesis what they hold now.blockedandrevoked_atare as the Followers page shows them.404 {"error":"not found"}Not on the list. To add them, go to step 9. 401 / 429 / otherSame as step 3. Add a person
Say to the human
Add <email> to <Pass name>, until <date or no end>? They stay Invited until they sign in, and get no email from you before that unless you want the welcome letter sent. Do you confirm they opted in to hear from you by email?Request
POST https://backstagepass.com/api/v1/people Authorization: Bearer <key> Content-Type: application/json {"email": "<email>", "pass_id": "<pass_id>", "expires_at": "<ISO 8601, optional>", "welcome": <true|false>, "attested": true}Expect
201 or 200, the person objectThen
No yes on opt-inStop. Send attested: trueonly when the human confirms it.No pass_idGET /api/v1/passes. Ask the human which Pass. 201They are Invited. Tell the human the Pass is theirs when they sign in with that email. 200 "status": "invited"Already Invited. New Passes joined the queue; a queued Pass's untilis nowexpires_at.200 "status": "signed_in"Already a follower. The Pass is granted now. 422Stop. Show the human the errorsentence. Do not retry until it is fixed.Revoke or block
Say to the human
Take <Pass name, or everything> from <email>? A paid membership stops renewing and runs to the end of its paid term, unless you want it closed now. Nothing is refunded.Request
POST https://backstagepass.com/api/v1/people/revoke Authorization: Bearer <key> Content-Type: application/json {"email": "<email>", "pass_id": "<pass_id, or leave out for everything>", "mode": "at_period_end"}Expect
200 the person object, plus revoked and skippedThen
revokedTell the human each line: what ended, and until when. skippedTell the human the reason. lifetime seat — nothing renews: ask whether to sendmode: "immediate"."status": "withdrawn"The invite is gone. A GET for them is now 404. To stop them coming backPOST /api/v1/people/block with {"email": "<email>"}. Undo with POST /api/v1/people/unblock. 422 "not signed in yet — withdraw the invite instead"Revoke without pass_idinstead.422 "One bulk act at a time — the last one hasn't finished."Wait 60 seconds. Try once more. 404 {"error":"not found"}Not on the list. Nothing to revoke.
What an agent cannot do today
| An agent cannot | Today |
|---|---|
| Add, list, ping or delete a webhook | Studio only. A human does it on the Webhooks card. |
| Change a webhook's signing secret | Not possible anywhere. Delete the row and add it again. |
| Create, replace or disable the API key | Studio only, account owner only. |
| Read a delivery log or resend a delivery | Not available. The row shows delivered and failed counts only. |
| Write anything but the People calls | The People calls are the only writes. Nothing can send, charge or change the account. |
| Refund a charge | Studio only: Transactions, one charge at a time. |
| Block someone who has not signed in | Not possible. Withdraw the invite instead: revoke without pass_id. |
| Read lives older than the newest 50 | Not available. /api/v1/streams has no paging. |
Machine files
| File | What |
|---|---|
https://backstagepass.com/developers/llms.txt | Index for language models (llmstxt.org). |
https://backstagepass.com/developers/llms-full.txt | This whole reference as markdown. |
https://backstagepass.com/developers/openapi.json | OpenAPI 3.1 for every endpoint. |
https://backstagepass.com/developers/webhooks.schema.json | JSON Schema 2020-12 for every webhook body. |
OpenAPI 3.1, inline
{
"openapi": "3.1.0",
"info": {
"title": "BackstagePass API",
"version": "1.0.0",
"summary": "Access to one creator's own BackstagePass data, and the Followers tab's verbs for one person.",
"description": "One key per creator account, scoped to that creator's own data. The People endpoints are the only writes: each does what Studio's Followers page does for one person, keyed by email. A Pass is named by its public id (pass_ plus 16 lowercase letters and digits); the integer id never appears. Responses are bare JSON objects (no envelope), except /api/v1/audience/export, which returns CSV. Generated from app/controllers/api/v1/*.rb."
},
"externalDocs": {
"description": "Guides, webhook events and the agent steps",
"url": "https://backstagepass.com/help/how/automations"
},
"servers": [
{
"url": "https://backstagepass.com",
"description": "Production"
}
],
"security": [
{
"bearerAuth": []
}
],
"tags": [
{
"name": "Account",
"description": "The creator the key belongs to."
},
{
"name": "Audience",
"description": "Your followers and imported contacts."
},
{
"name": "Streams",
"description": "Your ended lives and the notifications sent for them."
},
{
"name": "Passes",
"description": "Your Passes, by their public id."
},
{
"name": "People",
"description": "One person on your list, keyed by email: add, find, revoke, block and unblock."
}
],
"paths": {
"/api/v1/me": {
"get": {
"operationId": "getMe",
"tags": [
"Account"
],
"summary": "Your creator profile",
"description": "The creator the key belongs to.",
"responses": {
"200": {
"description": "The creator profile.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Me"
},
"examples": {
"default": {
"value": {
"handle": "maya",
"display_name": "Maya Rivers",
"plan": "free"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/api/v1/audience": {
"get": {
"operationId": "getAudience",
"tags": [
"Audience"
],
"summary": "Your audience list, as counts",
"description": "Aggregate counts only, no people. The reachable counts come from the same eligibility query the Followers page, the composer preview and the receipt use.",
"responses": {
"200": {
"description": "Audience counts.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Audience"
},
"examples": {
"default": {
"value": {
"fans": 1240,
"push_reachable": 610,
"email_reachable": 980,
"imported_awaiting_first_sign_in": 75,
"text_reachable": 0,
"imported": 300,
"imported_claimed": 120,
"generated_at": "2026-10-27T18:04:05Z"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/api/v1/audience/export": {
"get": {
"operationId": "exportAudience",
"tags": [
"Audience"
],
"summary": "The same CSV as Export on Followers",
"description": "One row per person: every follower, then unclaimed imported contacts. Byte-identical to the Export button on the Followers page. The body starts with a UTF-8 byte order mark. Streamed: no Content-Length and no Content-Disposition header.",
"responses": {
"200": {
"description": "CSV file. Header row = the columns listed in the schema description.",
"content": {
"text/csv; charset=utf-8": {
"schema": {
"type": "string",
"description": "Columns, in order: name, email, phone, active_access, expired_access, active_names, expired_names, lifetime_spend, spend_passes, spend_tickets, spend_drops, eligible_lives, showed_up, showed_up_%, encores_started, encore_started_%, total_watch_time, live_minutes, encore_minutes, reachable_push, reachable_email, reachable_sms, sms_consent, email_sent, email_opened, email_open_rate, push_sent, push_delivered, push_open_rate, last_attended, lives_ago, last_sign_in, joined, source, blocked, tags, notes"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/api/v1/streams": {
"get": {
"operationId": "listStreams",
"tags": [
"Streams"
],
"summary": "Your ended lives and their receipts",
"description": "Your 50 most recently ended lives, newest first. A live that was taken down is left out. No fan appears in this payload. No pagination and no filters.",
"responses": {
"200": {
"description": "Ended lives.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StreamList"
},
"examples": {
"default": {
"value": {
"streams": [
{
"title": "Friday soundcheck",
"slug": "friday-soundcheck",
"started_at": "2026-10-24T01:00:00Z",
"ended_at": "2026-10-24T02:10:00Z",
"peak_in_room": 99,
"hearts": 412,
"cut_retention": {
"before": 90,
"after": 72,
"pct": 80
},
"broadcasts": [
{
"trigger": "go_live",
"sent_at": "2026-10-24T01:00:12Z",
"receipt": {
"push": {
"sent": 1,
"delivered": 0,
"opened": 0,
"no_transport": 0,
"suppressed_quiet_hours": 0,
"suppressed_never_activated": 0,
"suppressed_no_consent": 0,
"suppressed_muted": 0,
"suppressed_signal_tier": 0,
"suppressed_digest": 0,
"suppressed_plan_cap": 0,
"suppressed_announcement_type": 0,
"suppressed_frequency_cap": 0,
"suppressed_push_primary": 0,
"failed": 0
}
},
"walked_in_10m": {
"push": 1
}
}
]
}
],
"generated_at": "2026-10-27T18:04:05Z"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/api/v1/passes": {
"get": {
"operationId": "listPasses",
"tags": [
"Passes"
],
"summary": "Every Pass you have, with its public id",
"description": "Every Pass on your account, oldest first, with the shelf's state word and every price it is sold at.",
"responses": {
"200": {
"description": "Your Passes.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PassList"
},
"examples": {
"default": {
"value": {
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"status": "published",
"prices": [
{
"cadence": "monthly",
"price_cents": 900
},
{
"cadence": "yearly",
"price_cents": 9000
}
]
}
],
"generated_at": "2026-10-29T12:00:00Z"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/api/v1/people": {
"get": {
"operationId": "getPerson",
"tags": [
"People"
],
"summary": "One person on your list, by email",
"description": "An Invited person or a signed-in one. A withdrawn invite is a 404.",
"parameters": [
{
"name": "email",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "email"
}
}
],
"responses": {
"200": {
"description": "The person.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Person"
},
"examples": {
"signed_in": {
"value": {
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": "1p",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "grant",
"until": "2026-12-31T23:59:00Z",
"active": true
}
]
}
},
"invited": {
"value": {
"status": "invited",
"id": null,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"follower": false,
"invited": true,
"revoked_at": null,
"blocked": false,
"access": "Invited",
"source": "API",
"passes": [],
"invited_at": "2026-10-29T12:00:00Z",
"welcome_sent": true,
"queued_passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"until": "2026-12-31T23:59:00Z"
}
]
}
}
}
}
}
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
},
"post": {
"operationId": "addPerson",
"tags": [
"People"
],
"summary": "Add one person, as the Add people sheet on Studio's Followers page does",
"description": "Adds them as Invited: no account and no follow are created, the Passes are queued on their invite, and the only email they can get before signing in is the welcome letter, when welcome is true (once ever per person). When they sign in they become a follower and every queued Pass is granted, carrying its until. Idempotent on email. Already a follower: the Pass is granted now, and a Pass they hold has its end set to expires_at. 422 when attested is not true, email is missing or unreadable, the phone cannot be read, the pass_id is unknown, archived or another creator's, expires_at has no Pass, is not ISO 8601 or is in the past, or the email is your own account.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email",
"attested"
],
"properties": {
"email": {
"type": "string",
"format": "email"
},
"attested": {
"type": "boolean",
"const": true,
"description": "You confirm this person consented to be added to your audience and to get email from you. Anything but true adds nobody."
},
"name": {
"type": "string",
"maxLength": 120,
"description": "If the person already has an account, their own name wins."
},
"phone": {
"type": "string",
"description": "Digits with country_code, or E.164 on its own. Stored on their contact row, never a sign-in."
},
"country_code": {
"type": "string",
"description": "US, GB or +44: reads phone, as the sheet's country picker does."
},
"pass_id": {
"type": "string",
"pattern": "^pass_[a-z0-9]{16}$",
"description": "Queues this Pass for them."
},
"pass_ids": {
"type": "array",
"items": {
"type": "string",
"pattern": "^pass_[a-z0-9]{16}$",
"description": "The Pass's public id. Studio's Passes page prints it with a Copy button."
},
"description": "Several Passes at once. May be sent beside pass_id."
},
"expires_at": {
"type": "string",
"format": "date-time",
"description": "In the future. Requires a Pass. The end of every Pass in this request. Leave it out for a grant with no end."
},
"welcome": {
"type": "boolean",
"default": false,
"description": "true sends the welcome letter, once ever per person."
}
}
},
"examples": {
"pass_with_expiry_welcome_on": {
"value": {
"email": "ada@example.com",
"name": "Ada Lovelace",
"pass_id": "pass_3f9a0c1e7b2d4a68",
"expires_at": "2026-12-31T23:59:00Z",
"welcome": true,
"attested": true
}
},
"welcome_off": {
"value": {
"email": "ada@example.com",
"pass_id": "pass_3f9a0c1e7b2d4a68",
"attested": true,
"welcome": false
}
}
}
}
}
},
"responses": {
"201": {
"description": "They were not on your list and are now Invited.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Person"
},
"examples": {
"welcome_on": {
"value": {
"status": "invited",
"id": null,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"follower": false,
"invited": true,
"revoked_at": null,
"blocked": false,
"access": "Invited",
"source": "API",
"passes": [],
"invited_at": "2026-10-29T12:00:00Z",
"welcome_sent": true,
"queued_passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"until": "2026-12-31T23:59:00Z"
}
]
}
},
"welcome_off": {
"value": {
"status": "invited",
"id": null,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"follower": false,
"invited": true,
"revoked_at": null,
"blocked": false,
"access": "Invited",
"source": "API",
"passes": [],
"invited_at": "2026-10-29T12:00:00Z",
"welcome_sent": false,
"queued_passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"until": "2026-12-31T23:59:00Z"
}
]
}
}
}
}
}
},
"200": {
"description": "They were already on your list (Invited, or a follower).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Person"
},
"examples": {
"already_a_follower": {
"value": {
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": "1p",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "grant",
"until": "2026-12-31T23:59:00Z",
"active": true
}
]
}
}
}
}
}
},
"422": {
"description": "The request cannot be done as sent. The error is one of these sentences.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"e1": {
"summary": "attested was not true.",
"value": {
"error": "Adding people needs your word first \u2014 confirm everyone here opted in to hear from you by email. Nobody was added."
}
},
"e2": {
"summary": "email is required",
"value": {
"error": "email is required"
}
},
"e3": {
"summary": "That email address cannot be read",
"value": {
"error": "That email address cannot be read"
}
},
"e4": {
"summary": "That phone number cannot be read \u2014 send it with its country code, like +14155550132",
"value": {
"error": "That phone number cannot be read \u2014 send it with its country code, like +14155550132"
}
},
"e5": {
"summary": "An unknown id, another creator's Pass, or an archived Pass.",
"value": {
"error": "No pass with that pass_id on your account"
}
},
"e6": {
"summary": "expires_at needs a pass_id",
"value": {
"error": "expires_at needs a pass_id"
}
},
"e7": {
"summary": "expires_at must be an ISO 8601 time, like 2026-12-31T23:59:00Z",
"value": {
"error": "expires_at must be an ISO 8601 time, like 2026-12-31T23:59:00Z"
}
},
"e8": {
"summary": "expires_at is in the past",
"value": {
"error": "expires_at is in the past"
}
},
"e9": {
"summary": "That is your own account",
"value": {
"error": "That is your own account"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/api/v1/people/revoke": {
"post": {
"operationId": "revokePerson",
"tags": [
"People"
],
"summary": "Revoke one Pass, or everything",
"description": "Invited person: with pass_id, that Pass comes off their queue; without, the invite is withdrawn (status withdrawn, and a GET is then a 404). Signed-in person: with pass_id, a grant ends now and a membership is cancelled at period end (at_period_end) or revoked now (immediate); without pass_id, this is the Followers tab's Revoke for this one person, and the follow row stays stamped Revoked. Nothing is refunded. 422 for a bad mode, an unknown pass_id, or while a Studio bulk act is still running and this person holds a paid membership.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"format": "email"
},
"pass_id": {
"type": "string",
"pattern": "^pass_[a-z0-9]{16}$",
"description": "Leave it out to revoke everything."
},
"mode": {
"type": "string",
"enum": [
"at_period_end",
"immediate"
],
"default": "at_period_end",
"description": "Only applies to a paid membership on that Pass."
}
}
},
"examples": {
"one_pass_at_period_end": {
"value": {
"email": "ada@example.com",
"pass_id": "pass_3f9a0c1e7b2d4a68",
"mode": "at_period_end"
}
},
"one_pass_immediate": {
"value": {
"email": "ada@example.com",
"pass_id": "pass_3f9a0c1e7b2d4a68",
"mode": "immediate"
}
},
"everything": {
"value": {
"email": "ada@example.com"
}
}
}
}
}
},
"responses": {
"200": {
"description": "The person, plus what was revoked and what was skipped.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RevokeResult"
},
"examples": {
"one_pass_at_period_end": {
"value": {
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": "1p",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"until": "2026-11-30T00:00:00Z",
"active": true
}
],
"revoked": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"until": "2026-11-30T00:00:00Z"
}
],
"skipped": []
}
},
"everything": {
"value": {
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": false,
"invited": false,
"revoked_at": "2026-10-29T12:00:00Z",
"blocked": false,
"access": "Revoked",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"until": "2026-11-30T00:00:00Z",
"active": false
},
{
"pass_id": "pass_9b0e2f6c1a3d5e70",
"name": "Back Room",
"kind": "grant",
"until": "2026-10-29T12:00:00Z",
"active": false
}
],
"revoked": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"until": "2026-11-30T00:00:00Z"
},
{
"pass_id": "pass_9b0e2f6c1a3d5e70",
"name": "Back Room",
"kind": "grant",
"until": "2026-10-29T12:00:00Z"
},
{
"name": "Tuesday coaching call",
"kind": "ticket"
},
{
"kind": "follow"
}
],
"skipped": []
}
},
"invite_withdrawn": {
"value": {
"status": "withdrawn",
"id": null,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": null,
"follower": false,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": null,
"source": "API",
"passes": [],
"revoked": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "queued"
},
{
"kind": "invite"
}
],
"skipped": []
}
},
"lifetime_seat_skipped": {
"value": {
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": "1p",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"until": null,
"active": true
}
],
"revoked": [],
"skipped": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "subscription",
"reason": "lifetime seat \u2014 nothing renews"
}
]
}
}
}
}
}
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"422": {
"description": "The request cannot be done as sent. The error is one of these sentences.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"e1": {
"summary": "email is required",
"value": {
"error": "email is required"
}
},
"e2": {
"summary": "mode must be at_period_end or immediate",
"value": {
"error": "mode must be at_period_end or immediate"
}
},
"e3": {
"summary": "No pass with that pass_id on your account",
"value": {
"error": "No pass with that pass_id on your account"
}
},
"e4": {
"summary": "A Studio bulk act is still running and this person holds a paid membership.",
"value": {
"error": "One bulk act at a time \u2014 the last one hasn't finished."
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/api/v1/people/block": {
"post": {
"operationId": "blockPerson",
"tags": [
"People"
],
"summary": "Block one person",
"description": "The Followers tab's Block. They cannot enter a live (open ones included), watch an encore or buy. Nothing they paid for is cancelled or refunded. Idempotent. 422 for somebody you invited who has no account yet: withdraw the invite instead.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"format": "email"
}
}
},
"examples": {
"by_email": {
"value": {
"email": "ada@example.com"
}
}
}
}
}
},
"responses": {
"200": {
"description": "The person, blocked true.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Person"
},
"examples": {
"default": {
"value": {
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": true,
"access": "Blocked",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "grant",
"until": "2026-12-31T23:59:00Z",
"active": true
}
]
}
}
}
}
}
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"422": {
"description": "Somebody you invited who has no account yet: withdraw the invite instead.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"e1": {
"summary": "Somebody you invited who has no account yet.",
"value": {
"error": "not signed in yet \u2014 withdraw the invite instead"
}
},
"e2": {
"summary": "email is required",
"value": {
"error": "email is required"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/api/v1/people/unblock": {
"post": {
"operationId": "unblockPerson",
"tags": [
"People"
],
"summary": "Unblock one person",
"description": "Undoes a block. Every door comes back as it was. Idempotent. 422 for somebody you invited who has no account yet: withdraw the invite instead.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"format": "email"
}
}
},
"examples": {
"by_email": {
"value": {
"email": "ada@example.com"
}
}
}
}
}
},
"responses": {
"200": {
"description": "The person, blocked false.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Person"
},
"examples": {
"default": {
"value": {
"status": "signed_in",
"id": 4812,
"name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+14155550132",
"follower": true,
"invited": false,
"revoked_at": null,
"blocked": false,
"access": "1p",
"source": "API",
"passes": [
{
"pass_id": "pass_3f9a0c1e7b2d4a68",
"name": "Front Row",
"kind": "grant",
"until": "2026-12-31T23:59:00Z",
"active": true
}
]
}
}
}
}
}
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"422": {
"description": "Somebody you invited who has no account yet: withdraw the invite instead.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"e1": {
"summary": "Somebody you invited who has no account yet.",
"value": {
"error": "not signed in yet \u2014 withdraw the invite instead"
}
},
"e2": {
"summary": "email is required",
"value": {
"error": "email is required"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"description": "Authorization: Bearer <api-key>. The key is generated by the account owner at Studio > Automations > APIs & Webhooks. 40 alphanumeric characters."
}
},
"responses": {
"Unauthorized": {
"description": "No key, an unknown key, or a disabled key.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"default": {
"value": {
"error": "unauthorized"
}
}
}
}
}
},
"RateLimited": {
"description": "More than 120 requests in one minute with the same Authorization header.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"default": {
"value": {
"error": "rate limited"
}
}
}
}
}
},
"NotFound": {
"description": "The email is not on your list (a withdrawn invite included). An account with nothing to do with you is a 404 too.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"default": {
"value": {
"error": "not found"
}
}
}
}
}
}
},
"schemas": {
"Error": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string",
"description": "Plain words. unauthorized (401), rate limited (429), not found (404), or the 422 sentence for the request."
}
}
},
"Me": {
"type": "object",
"required": [
"handle",
"display_name",
"plan"
],
"properties": {
"handle": {
"type": "string",
"description": "Your handle: backstagepass.com/<handle>."
},
"display_name": {
"type": "string"
},
"plan": {
"type": "string",
"description": "Internal plan identifier."
}
}
},
"Audience": {
"type": "object",
"required": [
"fans",
"push_reachable",
"email_reachable",
"imported_awaiting_first_sign_in",
"text_reachable",
"imported",
"imported_claimed",
"generated_at"
],
"properties": {
"fans": {
"type": "integer",
"description": "People who follow you."
},
"push_reachable": {
"type": "integer",
"description": "Followers a push notification can reach now."
},
"email_reachable": {
"type": "integer",
"description": "Followers an email can reach now. Does not include imported contacts who have not signed in."
},
"imported_awaiting_first_sign_in": {
"type": "integer",
"description": "Imported contacts eligible for email who have not signed in yet."
},
"text_reachable": {
"type": "integer",
"description": "Followers a text message can reach."
},
"imported": {
"type": "integer",
"description": "Contacts you imported."
},
"imported_claimed": {
"type": "integer",
"description": "Imported contacts who have since signed in."
},
"generated_at": {
"type": "string",
"format": "date-time"
}
}
},
"StreamList": {
"type": "object",
"required": [
"streams",
"generated_at"
],
"properties": {
"streams": {
"type": "array",
"maxItems": 50,
"items": {
"$ref": "#/components/schemas/Stream"
}
},
"generated_at": {
"type": "string",
"format": "date-time"
}
}
},
"Stream": {
"type": "object",
"required": [
"title",
"slug",
"started_at",
"ended_at",
"peak_in_room",
"hearts",
"cut_retention",
"broadcasts"
],
"properties": {
"title": {
"type": "string"
},
"slug": {
"type": "string",
"description": "The live's page: backstagepass.com/<handle>/s/<slug>."
},
"started_at": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"ended_at": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"peak_in_room": {
"type": "integer",
"description": "Most people in the room at once."
},
"hearts": {
"type": "integer",
"description": "Reactions sent between started_at and ended_at."
},
"cut_retention": {
"description": "Who stayed after CUT THE SOCIALS: the room count at the cut and 10 to 14 minutes later. null when the live was never cut or the samples are missing.",
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"required": [
"before",
"after",
"pct"
],
"properties": {
"before": {
"type": "integer"
},
"after": {
"type": "integer"
},
"pct": {
"type": "integer",
"description": "after / before, as a rounded percentage."
}
}
}
]
},
"broadcasts": {
"type": "array",
"description": "Every notification blast sent for this live, oldest first.",
"items": {
"$ref": "#/components/schemas/Broadcast"
}
}
}
},
"Broadcast": {
"type": "object",
"required": [
"trigger",
"sent_at",
"receipt",
"walked_in_10m"
],
"properties": {
"trigger": {
"type": "string",
"description": "What sent it.",
"enum": [
"go_live",
"stream_reminder",
"went_backstage",
"clips_ready",
"canceled",
"welcome",
"joined_backstage",
"win_back",
"waitlist_seat",
"ticket_bought",
"replay_watched",
"push_granted",
"gone_quiet",
"stream_ended",
"booking_reminder",
"digest",
"door_open",
"show_booked",
"new_pass",
"rescheduled",
"access_granted",
"counted_in"
]
},
"sent_at": {
"type": "string",
"format": "date-time"
},
"receipt": {
"type": "object",
"description": "Delivery counts per channel. A channel appears only when at least one of its counts is above zero.",
"propertyNames": {
"enum": [
"push",
"email",
"sms",
"whatsapp"
]
},
"additionalProperties": {
"$ref": "#/components/schemas/ChannelReceipt"
}
},
"walked_in_10m": {
"type": "object",
"description": "Per channel: distinct people who entered the room within 10 minutes of this notification reaching them. A channel with nobody is left out.",
"propertyNames": {
"enum": [
"push",
"email",
"sms",
"whatsapp"
]
},
"additionalProperties": {
"type": "integer"
}
}
}
},
"ChannelReceipt": {
"type": "object",
"required": [
"sent",
"delivered",
"opened",
"no_transport",
"suppressed_quiet_hours",
"suppressed_never_activated",
"suppressed_no_consent",
"suppressed_muted",
"suppressed_signal_tier",
"suppressed_digest",
"suppressed_plan_cap",
"suppressed_announcement_type",
"suppressed_frequency_cap",
"suppressed_push_primary",
"failed"
],
"properties": {
"sent": {
"type": "integer",
"description": "Rows marked sent or delivered."
},
"delivered": {
"type": "integer"
},
"opened": {
"type": "integer"
},
"no_transport": {
"type": "integer"
},
"suppressed_quiet_hours": {
"type": "integer"
},
"suppressed_never_activated": {
"type": "integer"
},
"suppressed_no_consent": {
"type": "integer"
},
"suppressed_muted": {
"type": "integer"
},
"suppressed_signal_tier": {
"type": "integer"
},
"suppressed_digest": {
"type": "integer"
},
"suppressed_plan_cap": {
"type": "integer"
},
"suppressed_announcement_type": {
"type": "integer"
},
"suppressed_frequency_cap": {
"type": "integer"
},
"suppressed_push_primary": {
"type": "integer"
},
"failed": {
"type": "integer"
}
}
},
"Pass": {
"type": "object",
"required": [
"pass_id",
"name",
"status",
"prices"
],
"properties": {
"pass_id": {
"type": "string",
"pattern": "^pass_[a-z0-9]{16}$",
"description": "The Pass's public id. Studio's Passes page prints it with a Copy button."
},
"name": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"published",
"link_only",
"archived",
"draft"
],
"description": "The word the Pass shelf uses."
},
"prices": {
"type": "array",
"description": "Every way the Pass is sold.",
"items": {
"type": "object",
"required": [
"cadence",
"price_cents"
],
"properties": {
"cadence": {
"type": "string",
"enum": [
"monthly",
"quarterly",
"half_yearly",
"yearly",
"lifetime"
]
},
"price_cents": {
"type": [
"integer",
"null"
]
}
}
}
}
}
},
"PassList": {
"type": "object",
"required": [
"passes",
"generated_at"
],
"properties": {
"passes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Pass"
}
},
"generated_at": {
"type": "string",
"format": "date-time"
}
}
},
"Person": {
"type": "object",
"required": [
"status",
"id",
"name",
"email",
"phone",
"follower",
"invited",
"revoked_at",
"blocked",
"access",
"source",
"passes"
],
"properties": {
"status": {
"type": "string",
"enum": [
"invited",
"signed_in",
"withdrawn"
],
"description": "invited: added, and not signed in since. signed_in: has signed in and is on your list. withdrawn: only in the answer to the revoke that withdrew the invite."
},
"id": {
"type": [
"integer",
"null"
],
"description": "The person's account id. null for somebody who has never signed in."
},
"name": {
"type": [
"string",
"null"
]
},
"email": {
"type": "string",
"format": "email"
},
"phone": {
"type": [
"string",
"null"
],
"description": "E.164."
},
"follower": {
"type": "boolean",
"description": "true when they follow you and the follow has not been revoked."
},
"invited": {
"type": "boolean",
"description": "true when status is invited."
},
"revoked_at": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "When you revoked them (the follow row stays on your list)."
},
"blocked": {
"type": "boolean",
"description": "true when you have blocked them. A block closes every door."
},
"access": {
"type": [
"string",
"null"
],
"description": "The Followers page's Access chip, word for word: Blocked, Revoked, a count such as 1p \u00b7 2t, Follower, or Invited. null for a withdrawn invite."
},
"source": {
"type": [
"string",
"null"
],
"description": "The Followers page's Source column: API, Manual, Organic, or a tracking link's label."
},
"passes": {
"type": "array",
"description": "One entry per Pass of yours they hold or used to hold. Entries that have run out are included with active: false.",
"items": {
"type": "object",
"required": [
"pass_id",
"name",
"kind",
"until",
"active"
],
"properties": {
"pass_id": {
"type": "string",
"pattern": "^pass_[a-z0-9]{16}$",
"description": "The Pass's public id. Studio's Passes page prints it with a Copy button."
},
"name": {
"type": "string"
},
"kind": {
"type": "string",
"enum": [
"grant",
"subscription"
],
"description": "grant (you gave it) or subscription (they bought it)."
},
"until": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "When it ends: the grant's expiry, or the membership's cancel date. null means no end is scheduled."
},
"active": {
"type": "boolean"
}
}
}
},
"invited_at": {
"type": "string",
"format": "date-time",
"description": "Invited only. When you added them."
},
"welcome_sent": {
"type": "boolean",
"description": "Invited only. Whether the welcome letter has gone."
},
"queued_passes": {
"type": "array",
"description": "Invited only. The Passes waiting for them, each with the until its grant will carry when they sign in (null for no end).",
"items": {
"type": "object",
"required": [
"pass_id",
"until"
],
"properties": {
"pass_id": {
"type": "string",
"pattern": "^pass_[a-z0-9]{16}$",
"description": "The Pass's public id. Studio's Passes page prints it with a Copy button."
},
"until": {
"type": [
"string",
"null"
],
"format": "date-time"
}
}
}
}
}
},
"RevokeResult": {
"allOf": [
{
"$ref": "#/components/schemas/Person"
},
{
"type": "object",
"required": [
"revoked",
"skipped"
],
"properties": {
"revoked": {
"type": "array",
"items": {
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"queued",
"invite",
"grant",
"subscription",
"ticket",
"follow"
]
},
"pass_id": {
"type": "string",
"pattern": "^pass_[a-z0-9]{16}$",
"description": "The Pass's public id. Studio's Passes page prints it with a Copy button."
},
"name": {
"type": "string"
},
"until": {
"type": [
"string",
"null"
],
"format": "date-time"
}
}
}
},
"skipped": {
"type": "array",
"items": {
"type": "object",
"required": [
"reason"
],
"properties": {
"pass_id": {
"type": "string",
"pattern": "^pass_[a-z0-9]{16}$",
"description": "The Pass's public id. Studio's Passes page prints it with a Copy button."
},
"name": {
"type": "string"
},
"kind": {
"type": "string"
},
"reason": {
"type": "string",
"description": "The bulk tool's own words, such as already winding down, lifetime seat \u2014 nothing renews, not active, holds nothing on this pass."
}
}
}
}
}
}
]
}
}
}
}
Webhook JSON Schema, inline
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://backstagepass.com/developers/webhooks.schema.json",
"title": "BackstagePass webhook payload",
"description": "The JSON body of every POST BackstagePass sends to a registered webhook URL. The event header X-Backstagepass-Event carries the same value as the body's event field. Draft for review (BSP-423); generated from app/services/webhooks/emit.rb.",
"oneOf": [
{
"$ref": "#/$defs/event_ping"
},
{
"$ref": "#/$defs/event_welcome"
},
{
"$ref": "#/$defs/event_seat_reserved"
},
{
"$ref": "#/$defs/event_going_live"
},
{
"$ref": "#/$defs/event_reminder_15m"
},
{
"$ref": "#/$defs/event_reminder_60m"
},
{
"$ref": "#/$defs/event_reminder_1d"
},
{
"$ref": "#/$defs/event_live_rescheduled"
},
{
"$ref": "#/$defs/event_live_canceled"
},
{
"$ref": "#/$defs/event_counted_in"
},
{
"$ref": "#/$defs/event_encore_published"
},
{
"$ref": "#/$defs/event_new_pass"
},
{
"$ref": "#/$defs/event_win_back"
},
{
"$ref": "#/$defs/event_joined_backstage"
},
{
"$ref": "#/$defs/event_ticket_bought"
},
{
"$ref": "#/$defs/event_gone_quiet"
},
{
"$ref": "#/$defs/event_new_transaction"
},
{
"$ref": "#/$defs/event_stream_live"
},
{
"$ref": "#/$defs/event_stream_cut"
},
{
"$ref": "#/$defs/event_stream_restored"
},
{
"$ref": "#/$defs/event_stream_ended"
},
{
"$ref": "#/$defs/event_replay_ready"
}
],
"$defs": {
"creator": {
"type": "object",
"additionalProperties": false,
"required": [
"handle"
],
"properties": {
"handle": {
"type": "string",
"description": "The creator's handle: backstagepass.com/<handle>."
}
}
},
"fan": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name",
"email",
"phone"
],
"description": "The one person this moment is about. All four keys are always present; email and phone are null when the fan has none.",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"email": {
"type": [
"string",
"null"
]
},
"phone": {
"type": [
"string",
"null"
],
"description": "E.164, e.g. +15551234567."
}
}
},
"stream": {
"type": "object",
"additionalProperties": false,
"required": [
"title",
"slug",
"status",
"started_at",
"ended_at",
"room_url"
],
"properties": {
"title": {
"type": "string"
},
"slug": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"scheduled",
"live",
"ended",
"canceled"
]
},
"started_at": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"ended_at": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"room_url": {
"type": "string",
"format": "uri",
"description": "The live's own page: https://backstagepass.com/<handle>/s/<slug>."
}
}
},
"stream_with_replay": {
"type": "object",
"additionalProperties": false,
"required": [
"title",
"slug",
"status",
"started_at",
"ended_at",
"room_url",
"replay_url"
],
"properties": {
"title": {
"type": "string"
},
"slug": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"scheduled",
"live",
"ended",
"canceled"
]
},
"started_at": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"ended_at": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"room_url": {
"type": "string",
"format": "uri",
"description": "The live's own page: https://backstagepass.com/<handle>/s/<slug>."
},
"replay_url": {
"type": "string",
"format": "uri",
"description": "Same address as room_url; the page plays the replay."
}
}
},
"pass": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
}
},
"transaction": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"kind",
"amount_cents",
"amount_after_fees_cents",
"occurred_at"
],
"properties": {
"id": {
"type": "integer"
},
"kind": {
"type": "string",
"enum": [
"subscription",
"ticket",
"tip",
"drop"
]
},
"amount_cents": {
"type": "integer"
},
"amount_after_fees_cents": {
"type": [
"integer",
"null"
],
"description": "Net reported by the payment processor. null means no net was reported, never zero."
},
"occurred_at": {
"type": [
"string",
"null"
],
"format": "date-time"
}
}
},
"event_ping": {
"title": "ping",
"description": "Sent by Send a ping on the card. Not subscribable; every endpoint can receive it.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator"
],
"properties": {
"event": {
"const": "ping"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
}
}
},
"event_welcome": {
"title": "welcome",
"description": "Someone followed you. Card label: Follow confirmation.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"fan"
],
"properties": {
"event": {
"const": "welcome"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"fan": {
"$ref": "#/$defs/fan"
}
}
},
"event_seat_reserved": {
"title": "seat_reserved",
"description": "You booked a live for a future time. Card label: Live scheduled.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "seat_reserved"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_going_live": {
"title": "going_live",
"description": "You went live (GO). Card label: Going live.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "going_live"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_reminder_15m": {
"title": "reminder_15m",
"description": "A scheduled live is 15 minutes out. Card label: 15 minutes before.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "reminder_15m"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_reminder_60m": {
"title": "reminder_60m",
"description": "A scheduled live is one hour out. Card label: 60 minutes before.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "reminder_60m"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_reminder_1d": {
"title": "reminder_1d",
"description": "A scheduled live is one day out. Card label: 1 day before.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "reminder_1d"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_live_rescheduled": {
"title": "live_rescheduled",
"description": "You moved a scheduled live to a new time. Card label: Live rescheduled.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "live_rescheduled"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_live_canceled": {
"title": "live_canceled",
"description": "You cancelled a scheduled live. Card label: Live cancelled.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "live_canceled"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_counted_in": {
"title": "counted_in",
"description": "One fan counted themselves in (RSVP) to a live. Card label: Seat reserved.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"fan",
"stream"
],
"properties": {
"event": {
"const": "counted_in"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"fan": {
"$ref": "#/$defs/fan"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_encore_published": {
"title": "encore_published",
"description": "A live's replay is ready to watch. Card label: Encore published.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "encore_published"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_new_pass": {
"title": "new_pass",
"description": "A Pass went on sale. Card label: New pass created.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"pass"
],
"properties": {
"event": {
"const": "new_pass"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"pass": {
"$ref": "#/$defs/pass"
}
}
},
"event_win_back": {
"title": "win_back",
"description": "A fan's Pass subscription was cancelled and has ended. Card label: Pass expired.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"fan"
],
"properties": {
"event": {
"const": "win_back"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"fan": {
"$ref": "#/$defs/fan"
}
}
},
"event_joined_backstage": {
"title": "joined_backstage",
"description": "A fan bought a Pass. Card label: Joined backstage.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"fan"
],
"properties": {
"event": {
"const": "joined_backstage"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"fan": {
"$ref": "#/$defs/fan"
}
}
},
"event_ticket_bought": {
"title": "ticket_bought",
"description": "A fan got a ticket to a live. Card label: Bought a ticket.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"fan",
"stream"
],
"properties": {
"event": {
"const": "ticket_bought"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"fan": {
"$ref": "#/$defs/fan"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_gone_quiet": {
"title": "gone_quiet",
"description": "A fan has not joined a room in 60 days. Only fires while the Became inactive automation is on. Card label: Became inactive.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"fan"
],
"properties": {
"event": {
"const": "gone_quiet"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"fan": {
"$ref": "#/$defs/fan"
}
}
},
"event_new_transaction": {
"title": "new_transaction",
"description": "A fan paid you: a Pass charge, a ticket, a tip or a drop. Card label: New transaction.",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"fan",
"transaction"
],
"properties": {
"event": {
"const": "new_transaction"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"fan": {
"$ref": "#/$defs/fan"
},
"transaction": {
"$ref": "#/$defs/transaction"
}
}
},
"event_stream_live": {
"title": "stream.live",
"description": "Legacy. Fires at GO, beside going_live. Card label: Went live (legacy).",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "stream.live"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_stream_cut": {
"title": "stream.cut",
"description": "Legacy. Every social feed was cut. Card label: Went backstage (legacy).",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "stream.cut"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_stream_restored": {
"title": "stream.restored",
"description": "Legacy. The social feeds came back after a cut. Card label: Feeds restored (legacy).",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "stream.restored"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_stream_ended": {
"title": "stream.ended",
"description": "Legacy. The live ended (END). Also fires for a live that was taken down. Card label: Show ended (legacy).",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "stream.ended"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream"
}
}
},
"event_replay_ready": {
"title": "replay.ready",
"description": "Legacy. Fires with encore_published. Card label: Replay ready (legacy).",
"type": "object",
"additionalProperties": false,
"required": [
"event",
"occurred_at",
"creator",
"stream"
],
"properties": {
"event": {
"const": "replay.ready"
},
"occurred_at": {
"type": "string",
"format": "date-time"
},
"creator": {
"$ref": "#/$defs/creator"
},
"stream": {
"$ref": "#/$defs/stream_with_replay"
}
}
}
}
}
Need help
Email support@backstagepass.com with your handle and, for webhooks, the endpoint URL and the X-Backstagepass-Delivery id.