Developers

Shopbird API

Automate locks, audiences, codes and drops from your own scripts, and get signed webhooks when things change. Included with every paid plan.

Base URL

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.

Tokens

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.

Scopes

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.

Versions

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.

Changes and checkout

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" }
  • Add ?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").
  • The 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.

Safe writes

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 and errors

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.

Rate limits

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.

Plans

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.

Codes

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.

Granting access from a bot or CRM

For a Discord bot or CRM, the simplest pattern is a tag audience:

  1. In Shopbird, make an audience with the condition Customer tag, say discord-gold, and link it to your locks.
  2. When someone qualifies, tag them: 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 access check

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.

Webhooks

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.

Reference

Locks

GET /locks

List locks, newest first

Scope locks:read Returns 200

ParameterInTypeDescription
enabled query string true or false
limit query string 1–100 (default 50)
cursor query string next_cursor from the previous page
POST /locks

Create a lock

Scope locks:write Returns 201

ParameterInTypeDescription
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)
Request body (11 fields)
FieldTypeDescription
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[]
GET /locks/{id}

Get a lock

Scope locks:read Returns 200

ParameterInTypeDescription
idrequired path string
PATCH /locks/{id}

Update a lock (send If-Match with its ETag)

Scope locks:write Returns 200

ParameterInTypeDescription
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
Request body (11 fields)
FieldTypeDescription
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[]
DELETE /locks/{id}

Delete a lock (send If-Match with its ETag)

Scope locks:write Returns 200

ParameterInTypeDescription
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
POST /locks/{id}/enable

Turn a lock on

Scope locks:write Returns 200

ParameterInTypeDescription
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)
POST /locks/{id}/disable

Turn a lock off

Scope locks:write Returns 200

ParameterInTypeDescription
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

GET /audiences

List audiences, newest first

Scope audiences:read Returns 200

ParameterInTypeDescription
limit query string 1–100 (default 50)
cursor query string next_cursor from the previous page
POST /audiences

Create an audience

Scope audiences:write Returns 201

ParameterInTypeDescription
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)
Request body (10 fields)
FieldTypeDescription
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[]
GET /audiences/{id}

Get an audience

Scope audiences:read Returns 200

ParameterInTypeDescription
idrequired path string
PATCH /audiences/{id}

Update an audience (send If-Match with its ETag)

Scope audiences:write Returns 200

ParameterInTypeDescription
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
Request body (10 fields)
FieldTypeDescription
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[]
DELETE /audiences/{id}

Delete an audience (send If-Match with its ETag)

Scope audiences:write Returns 200

ParameterInTypeDescription
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
GET /audiences/{id}/codes

List an audience's active codes, newest first

Scope codes:read Returns 200

ParameterInTypeDescription
idrequired path string
limit query string 1–100 (default 50)
cursor query string next_cursor from the previous page
POST /audiences/{id}/codes

Add codes (up to 100, or generate up to 100); all or nothing

Scope codes:write Returns 201

ParameterInTypeDescription
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)
Request body (2 fields)
FieldTypeDescription
codes object[]
generate integer
POST /audiences/{id}/codes/revoke

Revoke codes by ID or code; all or nothing

Scope codes:write Returns 200

ParameterInTypeDescription
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)
Request body (2 fields)
FieldTypeDescription
ids string[]
codes string[]

Settings

GET /settings

Get shop settings

Scope settings:read Returns 200

PATCH /settings

Update shop settings

Scope settings:write Returns 200

ParameterInTypeDescription
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
Request body (2 fields)
FieldTypeDescription
default_visibility buttons | price | details | page
default_hide_from_lists boolean

Sync

GET /sync

Whether the latest changes are live at checkout

Any token Returns 200

POST /sync

Push the shop's locks to Shopify now (Locksmith's install)

Scope sync:write Returns 202

ParameterInTypeDescription
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

POST /customers/{id}/tags

Add tags (ones your audiences use) to a customer

Scope customers:write Returns 200

ParameterInTypeDescription
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)
Request body (1 fields)
FieldTypeDescription
tagsrequired string[]
DELETE /customers/{id}/tags

Remove tags from a customer

Scope customers:write Returns 200

ParameterInTypeDescription
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
Request body (1 fields)
FieldTypeDescription
tagsrequired string[]

Access

POST /access/evaluate

Which locks a customer (by tags, sign-in and codes) would pass

Scope access:read Returns 200

ParameterInTypeDescription
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)
Request body (3 fields)
FieldTypeDescription
customerrequired object
codes string[]
lock_ids string[]

Drops

GET /drops

List drops, newest first

Scope drops:read Returns 200

ParameterInTypeDescription
limit query string 1–100 (default 50)
cursor query string next_cursor from the previous page
POST /drops

Create a drop (times in UTC)

Scope drops:write Returns 201

ParameterInTypeDescription
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)
Request body (16 fields)
FieldTypeDescription
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[]
GET /drops/{id}

Get a drop

Scope drops:read Returns 200

ParameterInTypeDescription
idrequired path string
PATCH /drops/{id}

Update a drop (send If-Match with its ETag)

Scope drops:write Returns 200

ParameterInTypeDescription
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
Request body (16 fields)
FieldTypeDescription
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[]
DELETE /drops/{id}

Delete a drop and the locks it owns (send If-Match with its ETag)

Scope drops:write Returns 202

ParameterInTypeDescription
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
GET /drops/{id}/raffle-entries

List a raffle's entries (customer IDs only), newest first

Scope drops:read Returns 200

ParameterInTypeDescription
idrequired path string
limit query string 1–100 (default 50)
cursor query string next_cursor from the previous page
POST /drops/{id}/raffle/draw

Draw a raffle's winners (entries close at launch)

Scope drops:write Returns 202

ParameterInTypeDescription
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

GET /webhook-endpoints

List webhook endpoints (without secrets), newest first

Scope webhooks:read Returns 200

ParameterInTypeDescription
limit query string 1–100 (default 50)
cursor query string next_cursor from the previous page

Webhook deliveries

GET /webhook-deliveries

List webhook deliveries, newest first

Scope webhooks:read Returns 200

ParameterInTypeDescription
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

Error codes

CodeStatusMeaning
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

Changelog

Within /api/v1 changes only add things. Anything that could break a caller ships under a new version.

v1 update (2026-10-24)

  • Drops: each paid plan launches a set number of drops per calendar month (UTC, counted by launch date). Creating a drop, or moving one into a month that is full, answers 402 plan_limit with the error on launch. Launches past the allowance are no longer billed.
  • Drops: raffle_notify accepts and returns flow, which sends each entrant's result as a "Raffle result" Shopify Flow trigger instead of an email.

v1 (2026-10)

First release:

  • Tokens with scopes (Settings > API access) and a per-token activity log.
  • Locks, audiences, codes, settings and sync.
  • Drops and raffles.
  • Customer tags (for bots and CRMs) and access evaluation.
  • Storefront access check at /apps/shopbird/access.
  • Webhooks (Standard Webhooks) with retries, a delivery log and secret rotation.
  • Dry runs, idempotency keys, ETags, cursor pagination, problem+json errors and rate limits.