/locks List locks, newest first
| Parameter | In | Type | Description |
|---|---|---|---|
enabled | query | string | true or false |
limit | query | string | 1–100 (default 50) |
cursor | query | string | next_cursor from the previous page |
Developers
Automate locks, audiences, codes and drops from your own scripts, and get signed webhooks when things change. Included with every paid plan.
Requests go to /api/v1 on the Shopbird app's address, written as
<your app URL> in the examples below. Create tokens in Shopbird under
Settings > API access. The machine-readable reference is at
/api/v1/openapi.json on the same host.
Create a token in Settings > API access. Pick a name and what it can do. The full token (sb_live_…) is shown once, so copy it then; Shopbird keeps only a hash. Revoke a token at any time and anything using it stops at once. Each token has an activity page listing its requests for the last 30 days.
Send the token in a header on every request:
curl https://<your app URL>/api/v1/locks \
-H "Authorization: Bearer sb_live_…"
Tokens in the query string are ignored.
| Scope | Lets the token |
|---|---|
locks:read / locks:write |
See / edit locks |
audiences:read / audiences:write |
See / edit audiences |
codes:read / codes:write |
See codes / create and revoke codes |
drops:read / drops:write |
See drops / edit drops and draw raffles |
customers:write |
Tag customers |
settings:read / settings:write |
See / edit settings |
sync:write |
Start syncs |
access:read |
Check who can unlock |
webhooks:read |
See webhook endpoints and deliveries |
These scopes can grant shoppers access, so give them only to systems you trust: audiences:write, codes:read (it returns working codes), codes:write, customers:write and drops:write.
No token can create tokens, manage webhook endpoints or see webhook secrets. Those stay in Settings.
The API lives under /api/v1. Within v1, changes only add things: new routes, new optional fields, new error codes. A change that could break a caller ships as /api/v2, and v1 then answers with Deprecation and Sunset headers at least 6 months before it stops. See changelog.md.
Writes are saved at once, but checkout enforces them only after Shopbird syncs them to Shopify, usually within seconds. Every write that changes what checkout enforces returns:
"sync": { "generation": 42, "status": "pending" }
?wait=true to wait up to 20 seconds for the change to be live; status becomes "synced" (or stays "pending" if it took longer, or "failed" if Shopify refused it for now). Your write is saved either way and Shopbird retries on its own, including rebuilding a saved drop's stage locks.GET /api/v1/sync shows synced_generation. Your change is live once it's at least your write's generation.POST /api/v1/sync asks for a sync now (Locksmith's "install").sync.completed webhook tells you without polling.Hand out codes after the write that created them reports synced, so they work the moment a shopper uses them.
Editing. Reads return an ETag (for example "v3"). PATCH and DELETE need it back as If-Match: without it you get 428 if_match_required, and if someone changed the resource since (in the admin or another script) you get 412 conflict. Read it again and retry. Locks a drop manages can't be edited directly (409 managed_by_drop); edit the drop.
Retrying. Send an Idempotency-Key header (any unique string) on writes. Once a write with that key succeeds, retrying with the same key returns its response (marked Idempotent-Replayed: true, with the ETag when the body is a versioned resource) instead of writing again. The replayed body is as it was for the first request, including sync.status, so check GET /api/v1/sync for the current state. A first attempt that fails isn't kept: the key is released and a retry runs the write again. A retry that arrives while the first is still running gets 409 idempotency_key_in_flight; retry after a moment. Keys are kept for 24 hours; reusing one with a different body is refused (422 idempotency_key_reused).
Trying. Add ?dry_run=true to any write. It's checked and answered as if it ran, but nothing is saved, synced or sent. A drop that would create a collection lists it under would_create.
Lists are newest first: ?limit= (1–100, default 50) and ?cursor= with the next_cursor from the previous page (null on the last page). A cursor or filter value Shopbird can't read is refused (422 invalid) rather than ignored.
Errors are RFC 9457 application/problem+json, with a stable code, a request_id (quote it to support) and, for validation, errors[] with each field's path (like targets[0].resource_id). The OpenAPI document lists every code.
Each token gets bursts of 40 requests, refilling at 120 a minute. Responses carry X-RateLimit-Limit (the burst, 40), X-RateLimit-Remaining (requests you can send now) and X-RateLimit-Reset (seconds until the burst is fully back); over the limit you get 429 with Retry-After. When Shopify itself is busy, Shopbird answers 503 with Retry-After: retry after that many seconds.
The API is on paid plans (and during a store's 14-day trial). On Free, every request answers 402 plan_limit, including reads, so a script stops cleanly instead of half-working. Writes that need more than the store's plan covers get the same 402: locking more pages than the plan allows, a drop launching in a calendar month (UTC) that already has the plan's monthly launches (the error's path is launch; this applies to new drops and to drops moved into that month), and on Free a whole-store lock, a raffle, made to order, early access windows or a second live drop. The problem's upgrade_url is Shopify's plan page for the store, and tier names the smallest plan that covers the change. Nothing already locked is ever unlocked by a plan change.
POST /api/v1/audiences/{id}/codes adds up to 100 codes per request, either your own (8–32 letters, numbers or dashes) or { "generate": 50 }. POST /api/v1/audiences/{id}/codes/revoke revokes by ID or code. Both are all or nothing.
Checkout keeps refusing a revoked code's tokens until they expire (about 10 days). It can track around 250 recently revoked codes that shoppers had redeemed; past that, writes that would grow the list are refused with 422 revocation_list_full until older ones expire: revoking codes, and linking an audience with recently revoked codes to more locks (a lock save, or a drop's early-access windows). Codes nobody redeemed don't count.
For a Discord bot or CRM, the simplest pattern is a tag audience:
discord-gold, and link it to your locks.POST /api/v1/customers/{customer id}/tags with { "tags": ["discord-gold"] } (scope customers:write). Remove it with DELETE.Tags work at checkout immediately; no sync is needed. Only tags some audience uses can be set this way (422 tag_not_in_use otherwise). Across all locks, checkout can check at most 100 distinct customer tags (422 tag_limit).
To check before you tag, POST /api/v1/access/evaluate with the customer's tags (and up to 10 codes) answers open, code or none per lock. Passwords and "account created before" still need the shopper to unlock on the storefront.
Storefront code can ask, for up to 50 URLs at once, whether each is locked for the current shopper:
const params = new URLSearchParams();
for (const url of ["/products/moon-ring", "/collections/vip"]) params.append("urls[]", url);
const access = await (await fetch(`/apps/shopbird/access?${params}`)).json();
// access["/products/moon-ring"] = { locked, access_granted, hide_resource, lock_ids, opened_lock_ids }
access_granted covers tags and sign-in; code unlocks live in the shopper's browser. Unknown, unpublished and hidden resources answer like unlocked ones, so this can't reveal unannounced products. It runs through Shopify's app proxy, so it only works on the Online Store.
Add endpoints in Settings > Webhooks and pick the events each gets:
lock.created, lock.updated, lock.deleted, audience.created, audience.updated, audience.deleted, audience_code.created, audience_code.revoked, code.redeemed, drop.created, drop.updated, drop.deleted, drop.stage_changed, raffle.entered, raffle.drawn, sync.completed, sync.failed.
code.redeemed reports a signed-in customer's first unlock with an audience code; their later unlocks with it are free and not reported again. Each guest unlock spends a use and is reported.
Each delivery is a POST with:
{ "type": "lock.updated", "timestamp": "2026-10-08T12:00:00.000Z", "data": { "lock_id": "…", "version": 4 }, "actor": { "type": "api", "token_id": "…" } }
data holds IDs (fetch the resource for details); actor is api, admin or system. Answer with any 2xx within 10 seconds. Failures retry after 1 m, 5 m, 30 m, 2 h, 6 h and 12 h. An endpoint that answers 410, or keeps failing for 3 days, is turned off and Settings says why. Settings lists each endpoint's deliveries and can resend one.
Deliveries are signed per Standard Webhooks. Verify them with the standardwebhooks package:
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.SHOPBIRD_WEBHOOK_SECRET); // whsec_…
app.post("/webhooks/shopbird", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = webhook.verify(req.body.toString(), req.headers);
} catch {
return res.sendStatus(400);
}
// Deliveries can repeat: skip ones whose webhook-id you've already handled.
handle(event);
res.sendStatus(204);
});
Rotating a secret (Settings > Webhooks > Rotate secret) gives a new one at once; the old one keeps signing alongside it for 24 hours, so update your receiver within a day.
/locks List locks, newest first
| Parameter | In | Type | Description |
|---|---|---|---|
enabled | query | string | true or false |
limit | query | string | 1–100 (default 50) |
cursor | query | string | next_cursor from the previous page |
/locks Create a lock
| Parameter | In | Type | Description |
|---|---|---|---|
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
| Field | Type | Description |
|---|---|---|
name | string | |
message | string | |
enabled | boolean | |
match_all | boolean | |
visibility | string | null | |
hide_from_lists | boolean | null | |
active_from | string | null | |
active_until | string | null | |
redirect_after | string | null | |
targets | object[] | |
audience_ids | string[] |
/locks/{id} Get a lock
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string |
/locks/{id} Update a lock (send If-Match with its ETag)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
If-Match | header | string | The ETag from your last read; required where the resource is versioned |
| Field | Type | Description |
|---|---|---|
name | string | |
message | string | |
enabled | boolean | |
match_all | boolean | |
visibility | string | null | |
hide_from_lists | boolean | null | |
active_from | string | null | |
active_until | string | null | |
redirect_after | string | null | |
targets | object[] | |
audience_ids | string[] |
/locks/{id} Delete a lock (send If-Match with its ETag)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
If-Match | header | string | The ETag from your last read; required where the resource is versioned |
/locks/{id}/enable Turn a lock on
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
/locks/{id}/disable Turn a lock off
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
/audiences List audiences, newest first
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | string | 1–100 (default 50) |
cursor | query | string | next_cursor from the previous page |
/audiences Create an audience
| Parameter | In | Type | Description |
|---|---|---|---|
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
| Field | Type | Description |
|---|---|---|
name | string | |
conditions | password | codes | tags | signedIn | confirm | joined[] | |
match_all | boolean | |
password | string | |
tags | string[] | |
tag_match_all | boolean | |
confirm_prompt | string | null | |
confirm_button | string | null | |
joined_before | string | null | |
codes | object[] |
/audiences/{id} Get an audience
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string |
/audiences/{id} Update an audience (send If-Match with its ETag)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
If-Match | header | string | The ETag from your last read; required where the resource is versioned |
| Field | Type | Description |
|---|---|---|
name | string | |
conditions | password | codes | tags | signedIn | confirm | joined[] | |
match_all | boolean | |
password | string | |
tags | string[] | |
tag_match_all | boolean | |
confirm_prompt | string | null | |
confirm_button | string | null | |
joined_before | string | null | |
codes | object[] |
/audiences/{id} Delete an audience (send If-Match with its ETag)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
If-Match | header | string | The ETag from your last read; required where the resource is versioned |
/audiences/{id}/codes List an audience's active codes, newest first
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
limit | query | string | 1–100 (default 50) |
cursor | query | string | next_cursor from the previous page |
/audiences/{id}/codes Add codes (up to 100, or generate up to 100); all or nothing
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
| Field | Type | Description |
|---|---|---|
codes | object[] | |
generate | integer |
/audiences/{id}/codes/revoke Revoke codes by ID or code; all or nothing
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
| Field | Type | Description |
|---|---|---|
ids | string[] | |
codes | string[] |
/settings Get shop settings
/settings Update shop settings
| Parameter | In | Type | Description |
|---|---|---|---|
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
If-Match | header | string | The ETag from your last read; required where the resource is versioned |
| Field | Type | Description |
|---|---|---|
default_visibility | buttons | price | details | page | |
default_hide_from_lists | boolean |
/sync Whether the latest changes are live at checkout
/sync Push the shop's locks to Shopify now (Locksmith's install)
| Parameter | In | Type | Description |
|---|---|---|---|
wait | query | string | true to wait up to 20 s for the sync to finish |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
/customers/{id}/tags Add tags (ones your audiences use) to a customer
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
| Field | Type | Description |
|---|---|---|
tagsrequired | string[] |
/customers/{id}/tags Remove tags from a customer
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
If-Match | header | string | The ETag from your last read; required where the resource is versioned |
| Field | Type | Description |
|---|---|---|
tagsrequired | string[] |
/access/evaluate Which locks a customer (by tags, sign-in and codes) would pass
| Parameter | In | Type | Description |
|---|---|---|---|
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
| Field | Type | Description |
|---|---|---|
customerrequired | object | |
codes | string[] | |
lock_ids | string[] |
/drops List drops, newest first
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | string | 1–100 (default 50) |
cursor | query | string | next_cursor from the previous page |
/drops Create a drop (times in UTC)
| Parameter | In | Type | Description |
|---|---|---|---|
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
| Field | Type | Description |
|---|---|---|
name | string | |
launch_at | string | |
teaser_at | string | null | |
preview_at | string | null | |
ends_at | string | null | |
sell_mode | first_come | raffle | made_to_order | |
raffle_opens_at | string | null | |
raffle_winners | integer | null | |
raffle_claim_hours | integer | |
raffle_unclaimed | release | keep | |
raffle_notify | csv | email | flow | |
mto_closes_at | string | null | |
mto_cap | integer | null | |
mto_ship_text | string | |
sources | object[] | |
windows | object[] |
/drops/{id} Get a drop
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string |
/drops/{id} Update a drop (send If-Match with its ETag)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
If-Match | header | string | The ETag from your last read; required where the resource is versioned |
| Field | Type | Description |
|---|---|---|
name | string | |
launch_at | string | |
teaser_at | string | null | |
preview_at | string | null | |
ends_at | string | null | |
sell_mode | first_come | raffle | made_to_order | |
raffle_opens_at | string | null | |
raffle_winners | integer | null | |
raffle_claim_hours | integer | |
raffle_unclaimed | release | keep | |
raffle_notify | csv | email | flow | |
mto_closes_at | string | null | |
mto_cap | integer | null | |
mto_ship_text | string | |
sources | object[] | |
windows | object[] |
/drops/{id} Delete a drop and the locks it owns (send If-Match with its ETag)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
If-Match | header | string | The ETag from your last read; required where the resource is versioned |
/drops/{id}/raffle-entries List a raffle's entries (customer IDs only), newest first
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
limit | query | string | 1–100 (default 50) |
cursor | query | string | next_cursor from the previous page |
/drops/{id}/raffle/draw Draw a raffle's winners (entries close at launch)
| Parameter | In | Type | Description |
|---|---|---|---|
idrequired | path | string | |
dry_run | query | true | true: validate and return the would-be result; nothing is saved |
wait | query | true | true: wait up to 20 s until the change is live at checkout |
Idempotency-Key | header | string | Retrying with the same key never applies the write twice (kept 24 h) |
/webhook-endpoints List webhook endpoints (without secrets), newest first
| Parameter | In | Type | Description |
|---|---|---|---|
limit | query | string | 1–100 (default 50) |
cursor | query | string | next_cursor from the previous page |
/webhook-deliveries List webhook deliveries, newest first
| Parameter | In | Type | Description |
|---|---|---|---|
endpoint_id | query | string | Only this endpoint's deliveries |
limit | query | string | 1–100 (default 50) |
cursor | query | string | next_cursor from the previous page |
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | No valid API token in Authorization: Bearer |
insufficient_scope | 403 | The token lacks the route's scope (see required_scope) |
not_found | 404 | No such endpoint, or the resource doesn't exist in this shop |
method_not_allowed | 405 | The path exists but not with this method |
invalid_json | 400 | The request body isn't valid JSON |
invalid | 422 | Validation failed; errors[] names each field |
if_match_required | 428 | Send If-Match with the resource's ETag |
conflict | 412 | The resource changed since your ETag; read it again |
managed_by_drop | 409 | A drop manages this lock (see drop_id); edit the drop |
audience_in_use_by_drop | 409 | A drop's early access uses this audience (see drops) |
drop_started | 409 | The drop has launched; its launch, windows and sell mode are fixed |
raffle_drawn | 409 | The raffle has been drawn; its settings are fixed |
raffle_not_closed | 409 | Raffle entries are still open (they close at launch) |
drops_open | 409 | A raffle is open or a drop is between its teaser and public launch |
revocation_list_full | 422 | Checkout can't track more recent revocations; revoke fewer or wait |
tag_limit | 422 | Locks would check more than 100 customer tags |
scopes_required | 403 | The shop hasn't granted the access this needs (see scopes); grant it in the Shopbird admin |
plan_limit | 402 | The shop's plan doesn't cover this (see upgrade_url) |
tag_not_in_use | 422 | Only tags an audience uses can be set on customers |
idempotency_key_reused | 422 | This Idempotency-Key was used with a different request |
idempotency_key_in_flight | 409 | A request with this Idempotency-Key is still running |
rate_limited | 429 | Too many requests or wrong codes; wait for Retry-After |
temporarily_unavailable | 503 | Busy, or Shopify is throttling; retry after Retry-After |
internal_error | 500 | Unexpected failure; retry, and quote request_id to support |
Within /api/v1 changes only add things. Anything that could break a caller ships under a new version.
402 plan_limit with the error on launch. Launches past the allowance are no longer billed.raffle_notify accepts and returns flow, which sends each entrant's result as a "Raffle result" Shopify Flow trigger instead of an email.First release:
/apps/shopbird/access.