Reference
REST API.
Read campaigns, customers, cancel sessions, and aggregate stats. JSON in, JSON out. Bearer-token auth. Rate-limited per token. The API is read-only today — the intended use case is custom dashboards and one-off scripts. For most workflows the dashboard or webhooks are easier.
Auth
Mint a token under Settings → API tokens— give it a name (e.g. “data warehouse”), click Create token, and copy the sk_live_… value. Each token is scoped to a single workspace and is shown onceat creation — we only store its hash. Lost tokens can't be recovered, only revoked + re-minted. Revoke from the same page; revoked tokens start returning 401 immediately.
curl https://www.trybackstop.com/api/v1/customers \
-H "Authorization: Bearer sk_live_xxxxxxxxxxxx"Send the token as Authorization: Bearer <token>. A missing header returns 401 authentication_required; a token that doesn't start with sk_live_, or one that's unknown or revoked, returns 401 invalid_token.
Endpoints
All endpoints are GET under /api/v1. List endpoints return up to 50 rows per page, newest-first.
GET /api/v1/customers— paginated list of mirrored customers. Filters:?email=(exact match, case-insensitive),?tag=,?cursor=.GET /api/v1/customers/:id— single customer (by Backstop UUID) with theirsubscriptionsandrecent_campaignsembedded.GET /api/v1/campaigns— recovery campaigns. Filters:?status=in_flight|recovered|lost|abandoned,?cursor=.GET /api/v1/campaigns/:id— single campaign with its embeddedcustomerandsubscriptionsummary.GET /api/v1/cancel_sessions— paginated list of cancel sessions. Filters:?outcome=(one ofsaved_discount,saved_pause,saved_downgrade,saved_other,canceled,abandoned),?cursor=<ISO timestamp>.GET /api/v1/stats— aggregate counts and rates. Same numbers as the dashboard KPIs, in JSON.
A list response is a Stripe-style envelope. Each row carries an object tag (customer, recovery_campaign, cancel_session):
GET /api/v1/campaigns?status=in_flight
{
"object": "list",
"data": [
{
"id": "8f3c…",
"object": "recovery_campaign",
"stripe_invoice_id": "in_1A…",
"amount_cents": 7900,
"currency": "usd",
"status": "in_flight",
"decline_code": "insufficient_funds",
"decline_category": "soft",
"recovered_via": null,
"first_failed_at": "2026-06-20T14:02:11Z",
"recovered_at": null,
"closed_at": null,
"customer_id": "1c9a…",
"subscription_id": "2e7b…"
}
],
"has_more": true,
"next_cursor": "2026-06-20T14:02:11Z"
}Monetary fields are integer minor units (amount_cents) — the stats endpoint also returns a display_currency and a has_multiple_currencies flag, since its totals are normalized across whatever currencies your campaigns ran in.
Pagination
Pagination is timestamp-cursor based, not offset based. Each list response includes next_cursor — an ISO 8601 timestamp of the last row — plus a boolean has_more. Pass next_cursor back as ?cursor= to fetch the next page. Rows come back newest-first, and the cursor bounds them to strictly before that timestamp.
Rate limits
Default limit is 60 requests per minute per token, measured over a sliding one-minute window. Need more? Hit reply on any Backstop email — we raise limits case-by-case rather than tiering.
Every response includes:
X-RateLimit-Limit— your per-minute ceiling (60)X-RateLimit-Remaining— requests left in the current windowX-RateLimit-Reset— seconds remaining until the window refills
Hitting the limit returns HTTP 429 with a Retry-Afterheader (in seconds). Back off and retry; we don't auto-ban tokens for 429s.
Errors
All errors return JSON in the same nested shape (mirroring Stripe). Read error.type to branch programmatically and error.message for a human-readable reason:
{
"error": {
"type": "rate_limit_exceeded",
"message": "60 requests per minute. Retry in 12s."
}
}error.type values you may see
authentication_required(401) — noAuthorizationheader.invalid_token(401) — token malformed, unknown, or revoked.invalid_param(400) — a badcursor,status,outcome, or non-UUIDid.not_found(404) — the requested customer or campaign isn't in your workspace.rate_limit_exceeded(429) — see Rate limits above.database_error(500) — on us; retry and, if it persists, hit reply.
On a 429 the wait time is also returned as a Retry-After response header (seconds) — there is no retry_after_seconds field in the body.
Want it broader?
We deliberately ship a small surface today. If you're missing an endpoint, hit reply on any Backstop email and tell us what you'd build with it — we add what actual use cases ask for, not theoretical completeness.
Related
- Webhooks — push events instead of pulling.