# BackstagePass API and webhooks: full reference > An HTTP API and signed outbound webhooks for one BackstagePass creator's own account. Base URL https://backstagepass.com. Auth header: Authorization: Bearer . Human page: https://backstagepass.com/help/how/automations OpenAPI 3.1: https://backstagepass.com/developers/openapi.json Webhook JSON Schema: https://backstagepass.com/developers/webhooks.schema.json ## Quick facts | | | |---|---| | Base URL | `https://backstagepass.com` | | Auth header | `Authorization: Bearer ` | | Methods | `GET`, and `POST` for the People calls. A `POST` body is JSON (`Content-Type: application/json`). | | Responses | The object itself, no envelope. `/api/v1/audience/export` returns CSV. Times are ISO 8601, UTC. | | Rate limit | 120 requests per minute per key. Over that, `429` until the minute passes. No rate-limit headers. | | Version | In the path: `/api/v1`. No version header. This is the only version. | ## The API key Get it: Studio > Automations > APIs & Webhooks > API access > Generate new key. | | | |---|---| | 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 `401` at once. | | Scope | Your account's data only. The People calls are the only writes. | ## Errors | 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":""}` | 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/me Your creator profile. No parameters. ```bash curl https://backstagepass.com/api/v1/me \ -H "Authorization: Bearer $BSP_API_KEY" ``` ```json { "handle": "maya", "display_name": "Maya Rivers", "plan": "free" } ``` | Field | Type | Meaning | |---|---|---| | `handle` | string | Your handle: backstagepass.com/. | | `display_name` | string | Your name as fans see it. | | `plan` | string | Internal plan identifier. | ### GET /api/v1/audience Your audience, as counts. Counts only, no people. The reachable numbers are the same ones the Followers page and the composer show. No parameters. ```bash curl https://backstagepass.com/api/v1/audience \ -H "Authorization: Bearer $BSP_API_KEY" ``` ```json { "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). | ### GET /api/v1/audience/export Every 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. ```bash curl https://backstagepass.com/api/v1/audience/export \ -H "Authorization: Bearer $BSP_API_KEY" \ -o audience.csv ``` Content-Type `text/csv; charset=utf-8`. UTF-8 with a byte order mark. Streamed: no Content-Length, no Content-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 ### GET /api/v1/streams Your 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. ```bash curl https://backstagepass.com/api/v1/streams \ -H "Authorization: Bearer $BSP_API_KEY" ``` ```json { "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//s/. | | `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). | ## 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. Human page: https://backstagepass.com/help/how/automations#people ### The Invited rule 1. 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. 2. The one email they can get before signing in is the welcome letter, only when `welcome` is `true`. It goes once ever per person. 3. 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`. 4. Apart from the welcome letter, nobody gets email from you until they have signed in at least once. 5. 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": ""}`. - 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":""}` | 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/passes Every Pass on your account, oldest first, with the shelf's state word and every price it is sold at. No parameters. ```bash curl "https://backstagepass.com/api/v1/passes" \ -H "Authorization: Bearer $BSP_API_KEY" ``` ```json { "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). | ### 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: ```json { "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: ```json { "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/people Studio > Followers > Add people for one person. It adds them as Invited (see The Invited rule). It is idempotent on email. ```bash 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. 201, welcome on: ```json { "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" } ] } ``` 201, welcome off (send `"welcome": false`, or leave it out; no email goes): ```json { "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, already a follower: ```json { "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, `until` becomes this request's `expires_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 cases: - `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` ### GET /api/v1/people?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). ```bash curl "https://backstagepass.com/api/v1/people?email=ada@example.com" \ -H "Authorization: Bearer $BSP_API_KEY" ``` ### POST /api/v1/people/revoke ```bash 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 `access` becomes `Revoked`. They can re-follow or re-buy on their own; block them to stop that. `200`: the person object plus what happened. One Pass, a membership at period end: ```json { "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: ```json { "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": [] } ``` An invite withdrawn: ```json { "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": [] } ``` A lifetime seat skipped: ```json { "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: - `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 cases: - `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. ### POST /api/v1/people/block ```bash 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". ```json { "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 cases: - `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. ### POST /api/v1/people/unblock ```bash 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. 422 cases: - `not signed in yet — withdraw the invite instead`: Somebody you invited who has no account yet. - `email is required` ## Webhooks You add a URL in Studio and pick moments. When a moment happens, BackstagePass sends one POST with a JSON body to every URL subscribed to it. Verify the signature and answer 2xx. A webhook sends whether or not that moment's fan notification is on in Automations; `gone_quiet` is the one exception. Breakout and after-party rooms never send. ### Add an endpoint Studio > Automations > APIs & Webhooks > Webhooks: paste the URL, untick moments you don't want, press Add the webhook. The row shows the signing secret. | | | |---|---| | 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. | ### Events | Wire name | Studio label | Plain words | Fires when | Fan | Objects | How often | |---|---|---|---|---|---|---| | `welcome` | Follow confirmation | Someone joined your list | Someone follows you. | yes | fan | Once per new follow. | | `seat_reserved` | Live scheduled | You booked a live | You book a live for a future time. | no | stream | Once per live. | | `going_live` | Going live | You went live | You go live (GO). | no | stream | Once per live. | | `reminder_15m` | 15 minutes before | Your live starts in 15 minutes | A scheduled live is 15 minutes out. | no | stream | Once per live. | | `reminder_60m` | 60 minutes before | Your live starts in an hour | A scheduled live is one hour out. | no | stream | Once per live. | | `reminder_1d` | 1 day before | Your live is a day away | A scheduled live is one day out. | no | stream | Once per live. | | `live_rescheduled` | Live rescheduled | You moved a live | You move a scheduled live to a new time. | no | stream | Once per move. | | `live_canceled` | Live cancelled | You cancelled a live | You cancel a scheduled live. | no | stream | Once per live. | | `counted_in` | Seat reserved | A fan RSVP'd to a live | One fan counts themselves in (RSVP) to a live. | yes | fan, stream | Once per RSVP. | | `encore_published` | Encore published | Your replay is ready | A live's replay is ready to watch. | no | stream | Not deduplicated. | | `new_pass` | New pass created | You put a Pass on sale | A Pass goes on sale (listed). | no | pass | Once per Pass. | | `win_back` | Pass expired | A fan's Pass ended | A fan's Pass ends after a cancel (not a pause). | yes | fan | Each time it happens. | | `joined_backstage` | Joined backstage | A fan bought a Pass | A fan buys a Pass. | yes | fan | Once per purchase. | | `ticket_bought` | Bought a ticket | A fan got a ticket | A fan gets a ticket to a live. | yes | fan, stream | Once per ticket. | | `gone_quiet` | Became inactive | A fan went quiet for 60 days | Daily sweep: a fan's last activity crossed 60 days. Only while the Became inactive automation is on. | yes | fan | At most once per fan per 90 days. | | `new_transaction` | New transaction | A fan paid you | A fan pays you: Pass charge, ticket, tip or drop. | yes | fan, transaction | Once per payment. | Crossed names: `seat_reserved` is the Live scheduled moment (you booked a live); `counted_in` is the Seat reserved moment (one fan RSVP'd). Match on the wire name. ### Payload fields Every body has `event`, `occurred_at` (UTC) and `creator.handle`. Key order is not meaningful. | 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. | ### Example bodies #### welcome (Follow confirmation) Someone follows you. ```json { "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. ```json { "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). ```json { "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. ```json { "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. ```json { "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. ```json { "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. ```json { "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. ```json { "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. ```json { "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. ```json { "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). ```json { "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). ```json { "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. ```json { "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. ```json { "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. ```json { "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. ```json { "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. ```json { "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). ```json { "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. ```json { "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. ```json { "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. ```json { "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 Sent by Send a ping on the card. Every endpoint can receive it. ```json { "event": "ping", "creator": { "handle": "maya" }, "occurred_at": "2026-10-24T01:00:03Z" } ``` ### Headers | 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=,v1= | ### Verify the signature 1. Read the raw body bytes. Do not parse and re-serialize. 2. Split the header on `,` and `=` to get `t` and `v1`. 3. Compute lowercase hex HMAC-SHA256(secret, t + "." + raw_body). 4. Compare to `v1` in constant time. 5. Recommended: reject a `t` more than a few minutes from your clock. Each retry is signed fresh. Worked example: - Secret: `Xk3n9QpL2vR7tY1wZ8bC4dF6gH0jM5sA2eU7iO9q` - t: `1792803605` - Raw body: `{"event":"ping","creator":{"handle":"maya"},"occurred_at":"2026-10-24T01:00:03Z"}` - Header: `t=1792803605,v1=1e4bdaf501c8215c92763979b19eddeae854819028cda18cc826631964440a04` ```js 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 end ``` ```python 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 "", 200 ``` ### Delivery 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. | `going_live` and `stream.live` both fire at GO; `encore_published` and `replay.ready` both fire when the replay is ready. Subscribe to one of each pair. ### Legacy events Off the picker; still delivered to endpoints that already hold 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. | ### 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. | ## Integrate as an agent ### Step 1. Read the machine files Request: ```http GET https://backstagepass.com/developers/llms-full.txt ``` Expect: ``` 200, text/plain: this whole reference as markdown. ``` Then: - Always: Use `https://backstagepass.com/developers/openapi.json` for request shapes and `https://backstagepass.com/developers/webhooks.schema.json` to validate webhook bodies. ### Step 2. 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 else: Ask again. Do not continue without a key. ### Step 3. Prove the key Request: ```http GET https://backstagepass.com/api/v1/me Authorization: Bearer ``` Expect: ``` 200 { "handle": "maya", "display_name": "Maya Rivers", "plan": "free" } ``` Then: - 200: Tell 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 else: Stop. Report the status and body to the human. ### Step 4. Read what you need Request: ```http GET https://backstagepass.com/api/v1/audience GET https://backstagepass.com/api/v1/streams GET https://backstagepass.com/api/v1/audience/export ``` Expect: ``` 200 JSON counts / 200 JSON {"streams": [...], "generated_at": ...} / 200 text/csv ``` Then: - CSV: Strip the leading byte order mark. It holds names, emails and phones: treat it as personal data. - streams: At most the 50 newest ended lives. Older lives cannot be read. - 401 / 429 / other: Same as step 3. - Pace: Stay under 120 requests a minute. ### Step 5. Ask the human to add your webhook Say to the human: "Please open Studio > Automations > APIs & Webhooks. On Webhooks, paste , leave only these moments ticked: , press Add the webhook, and paste me the signing secret shown on that row." Expect: ``` A 40-character signing secret. ``` Then: - Refused URL: The card says "That doesn't look like a URL we can POST to." Give a public https:// URL with no username or password. - No secret: Wait. You cannot verify anything without it. ### Step 6. Receive each POST Request: ```http POST Content-Type: application/json X-Backstagepass-Event: X-Backstagepass-Delivery: X-Backstagepass-Signature: t=,v1= ``` Expect: ``` hex(HMAC-SHA256(secret, ".")) equals v1. ``` Then: - Signature mismatch: Answer 400. Do not act on the body. Stop and report to the human: the secret may be from another row. - Signature matches: Answer 200 within 10 seconds, then do the work. - Delivery id seen before: Answer 200. Do nothing else. - event is ping: Answer 200. Nothing to do. - event you do not know: Answer 200 and ignore it. Re-read the schema later. ### Step 7. 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 minute: Ask the human whether the row says paused, and whether the URL is public HTTPS. - Row's failed count went up: Your receiver answered non-2xx, redirected, or took over 10 seconds. ### Step 8. Find a person Request: ```http GET https://backstagepass.com/api/v1/people?email= Authorization: Bearer ``` Expect: ``` 200 the person object ``` Then: - "status": "invited": Added, not signed in yet. They hold nothing; `queued_passes` is what they get when they sign in. Nothing to block yet. - "status": "signed_in": `passes` is what they hold now. `blocked` and `revoked_at` are as the Followers page shows them. - 404 {"error":"not found"}: Not on the list. To add them, go to step 9. - 401 / 429 / other: Same as step 3. ### Step 9. Add a person Say to the human: "Add to , until ? 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: ```http POST https://backstagepass.com/api/v1/people Authorization: Bearer Content-Type: application/json {"email": "", "pass_id": "", "expires_at": "", "welcome": , "attested": true} ``` Expect: ``` 201 or 200, the person object ``` Then: - No yes on opt-in: Stop. Send `attested: true` only when the human confirms it. - No pass_id: GET /api/v1/passes. Ask the human which Pass. - 201: They 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 `until` is now `expires_at`. - 200 "status": "signed_in": Already a follower. The Pass is granted now. - 422: Stop. Show the human the `error` sentence. Do not retry until it is fixed. ### Step 10. Revoke or block Say to the human: "Take from ? A paid membership stops renewing and runs to the end of its paid term, unless you want it closed now. Nothing is refunded." Request: ```http POST https://backstagepass.com/api/v1/people/revoke Authorization: Bearer Content-Type: application/json {"email": "", "pass_id": "", "mode": "at_period_end"} ``` Expect: ``` 200 the person object, plus revoked and skipped ``` Then: - revoked: Tell the human each line: what ended, and until when. - skipped: Tell the human the reason. `lifetime seat — nothing renews`: ask whether to send `mode: "immediate"`. - "status": "withdrawn": The invite is gone. A GET for them is now 404. - To stop them coming back: POST /api/v1/people/block with {"email": ""}. Undo with POST /api/v1/people/unblock. - 422 "not signed in yet — withdraw the invite instead": Revoke without `pass_id` instead. - 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 - 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. ## Help support@backstagepass.com, with your handle and, for webhooks, the endpoint URL and the X-Backstagepass-Delivery id.