Admin API reference (v1)
https://app.curobi.com/api/admin/v1Server-to-server, JSON in and JSON out, authenticated with a secret key. Mostly read-only, with three exceptions — in order of how much they can break.
- Subscription writes under
write:subscriptions. Pause, resume, cancel, skip, reschedule, and edit a line’s quantity or variant. These change a merchant’s own data and move money, so they carry obligations nothing else here does: a mandatoryIdempotency-Key, a tenancy check against contracts Curobi created, and a receipt rather than the resource. - Webhook endpoint management under
manage:webhooks. It earns its place by not being subscription data — an endpoint is part of the API’s own plumbing, so a key that can start and stop event delivery still cannot change a contract, a plan or a price. POST /exports, which is a write in HTTP grammar only. It creates no merchant data: it queues a read of rows the same key could already page through, and demands exactly the read scopes that paging would.
A POST, PUT, PATCH or DELETE anywhere else returns
405 method_not_allowed — after authenticating, so an
unauthenticated probe still gets a 401 and learns nothing about which paths exist.
Conventions
Pagination
Cursor-based, on the same (created_at desc, id desc) keyset the CSV exports use — so an API crawl
and a CSV export of the same resource reconcile row for row.
GET /subscriptions?limit=100
→ { "data": [ … ], "next_cursor": "eyJ0IjoiMjAy…" }
GET /subscriptions?limit=100&cursor=eyJ0IjoiMjAy…
→ { "data": [ … ], "next_cursor": null } ← end of the crawllimitdefaults to 50, maximum 250. An over-large value is clamped, not rejected.- Cursors are opaque. Don’t construct, decode or store one beyond the crawl it belongs to. A
malformed cursor is
400 invalid_cursor— never a silent restart from page one. - Keep paging until
next_cursorisnull.
Keyset rather than offset, because a row created mid-crawl shifts every ?page= result by one and
the crawler silently skips a record. The id tie-break covers rows written in the same
millisecond — a bulk import, or a webhook fan-out.
Idempotency
Every subscription write requires an Idempotency-Key header. Not
honoured-if-present — required, and this is the one place the API insists on something you could
reasonably expect to be optional.
Idempotency-Key: 3f1c8a2e-9d47-4d1b-b0a4-6f2c5b7e1d90Any unique string per logical request, 255 printable-ASCII characters or fewer. A UUID is fine.
The reason is asymmetry. A re-sent POST /subscriptions/{id}/skip moves the next charge forward a
second interval, and nothing afterwards — not your logs, not the merchant’s admin, not ours —
says it happened twice. A client cannot tell a lost response from a lost request, so “retry on
timeout” is correct client behaviour; this is the server’s half of that bargain. Making the header
optional would trade a loud 400 an integrator fixes once for a silent billing bug nobody finds.
| Situation | What happens |
|---|---|
| First request under the key | Runs, and the outcome is stored |
| Retry, same key, same request | The first outcome is returned verbatim, with Idempotency-Replayed: true. Nothing runs again |
| Retry, same key, different request | 409 idempotency_conflict. A key identifies one request — use a new one |
| Retry while the first is still in flight | 409 idempotency_conflict. Wait for the first to answer |
| The first request failed | The failure is replayed, including a 500 |
Three details worth knowing before you build a retry loop:
- A key buys one request, not one endpoint. Uniqueness is per shop and key, not per shop, key and path. Under the wider scoping, a client that mints one key per retry batch and fires it at two paths performs two writes under a key it believes bought one. The request itself is matched on a hash of method, path and body, and JSON key order is not part of it — so a body serialized from a map still matches its own retry.
- Failures are replayed, not released. Once the key is claimed we cannot know whether Shopify’s
mutation landed before the error, so a blind retry under the same key returns the recorded failure
rather than firing a second mutation. Minting a new key is how you say I looked at the
contract first, which is the one decision only a human can make. Validation that runs before the
mutation does not spend the key — a malformed
next_billing_at, a variant id that isn’t a variant GID, a contract that isn’t yours, a missing scope. Fix those and retry with the same key. - Keys are forgotten after 24 hours. Long enough to outlast any backoff ladder; short enough that the table is a buffer rather than an audit log. The durable record of what a write did is the contract in Shopify and the webhook it produced.
Reads and the other two writes — POST /exports and /webhooks/endpoints — do not require the
header and ignore it. They are already self-limiting: an export is a read behind a concurrency
slot, and an endpoint is a visible row a merchant can see and delete.
Rate limits
60 requests/minute sustained, burst 120, per shop. Successful responses carry your remaining budget:
X-Curobi-RateLimit-Limit: 120
X-Curobi-RateLimit-Remaining: 118
X-Curobi-RateLimit-Reset: 1755345600A 429 carries Retry-After. Watch Remaining and slow down before you’re refused — the headers
are on successful responses precisely so a well-behaved client never sees a 429.
Versioning
Path-versioned, and additive-only within /v1. New fields may appear; existing fields never
change meaning and are never removed. Parse defensively — an unknown field is not an error, and a
client that rejects one will break on a release that broke nothing.
Request ids
X-Curobi-Request-Id is on every response, success or failure, and is logged on our side. It’s
time-sortable, so quoting one in a support request narrows our log search to a range scan.
What the data is
Every value is Curobi’s local mirror of what Shopify last told it. Shopify owns the contract; this API is not an authority on money. Reconcile against Shopify before billing anything against these figures. And only contracts Curobi created appear at all — see the scope limit.
This API cannot, on its own, drive a cart
Read this before you plan a headless build against the Admin API alone.
/plans, /bundles and /mix-match/{id} return selling_plan_group_id — the group. The
identifier a cart line must actually carry is the child selling plan id, and it appears nowhere
on this surface except on existing subscription rows, where it is the plan a contract already uses.
That is deliberate rather than an omission: Shopify owns the child plan and re-creates it whenever
the merchant edits the offer, so a stored copy would be right until the first edit and silently wrong
thereafter. But it means an integrator building server-side against /bundles gets all the way to
add-to-cart before discovering the gap.
Two ways to close it, both fine:
- Shopify’s own Storefront API, which returns a product’s selling plans natively. Every headless build already has this in hand.
- Curobi’s Storefront API (
curobi_pk_…), where every resource returnsselling_plan_id, resolved live.
The app.bundle_plan and app.mix_match metafields also carry the resolved selling-plan id and are
public-read through Shopify’s Storefront API — see
the config metafields.
Subscriptions
Scope: read:subscriptions (+ read:customers for customer identity)
GET /subscriptions
?status=ACTIVE &plan_id= &customer_id= &updated_after= &cursor= &limit=
GET /subscriptions/{contract_gid}plan_idmatchesselling_plan_group_id— the join key the Plans CSV export calls “Plan group ID”.updated_aftertakes an ISO 8601 timestamp. An unparseable value is400 invalid_requestrather than being ignored, because silently dropping a filter returns more data than was asked for.- The single-contract form additionally carries a
giftobject when the contract is a gift, andnullwhen it isn’t — so the absence of the relationship is itself an answer.
Subscription writes
Scope: write:subscriptions
POST /subscriptions/{contract_gid}/pause
POST /subscriptions/{contract_gid}/resume
POST /subscriptions/{contract_gid}/cancel
POST /subscriptions/{contract_gid}/skip
POST /subscriptions/{contract_gid}/reschedule { "next_billing_at": "2026-12-01T00:00:00Z" }
PATCH /subscriptions/{contract_gid}/lines/{line_gid} { "quantity": 2, "variant_id": "gid://…" }These call the same internal functions the in-app admin calls, so the API and the admin cannot disagree about what pausing means.
Every one of them requires an Idempotency-Key. And write:subscriptions does
not imply read:subscriptions — a key that only writes can do all six and read nothing.
The contract GID goes in the path, percent-encoded:
curl -sS -X POST \
"https://app.curobi.com/api/admin/v1/subscriptions/gid%3A%2F%2Fshopify%2FSubscriptionContract%2F42/pause" \
-H "Authorization: Bearer $CUROBI_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"What each one does
| Endpoint | Effect |
|---|---|
pause | Billing stops until the contract is resumed |
resume | A paused or failed contract goes back to active |
cancel | Terminal. The customer is no longer billed |
skip | The next charge is pushed forward by exactly one billing interval, or to the next charge date on a fixed-calendar plan |
reschedule | The next charge moves to the date you name |
PATCH …/lines/{id} | Changes one line’s quantity, its variant_id, or both, through Shopify’s draft flow. The line keeps its selling plan |
skip moves the next billing date — it does not flag a cycle. Shopify’s own “skip a billing cycle”
marks a cycle skipped and leaves the contract’s next billing date on the old date, so Shopify’s
admin, Curobi’s admin and this API would all keep reporting a charge that is not going to happen.
Moving the date instead makes every view agree, and it is exactly what the in-app Skip button
does, so the two paths converge rather than producing two different truths. It also means skip is
not naturally idempotent, which is the single strongest reason the Idempotency-Key is
mandatory.
reschedule takes an ISO 8601 timestamp that must be in the future and within five years. The
ceiling is a typo guard, not a policy — Shopify will happily accept the year 2270.
A line edit sends quantity (a whole number ≥ 1), variant_id (a
gid://shopify/ProductVariant/…), or both. At least one is required. Swaps are subject to
Shopify’s own rules: a variant outside the line’s selling-plan group is refused with
400 invalid_request.
Fixed-calendar plans
A contract whose billing policy carries anchors — a plan whose schedule.mode is "anchored", see
Plans — only bills on those dates, so neither action can leave it anywhere else:
skipmoves it to the next anchor, not one interval later. On a quarterly plan that is three months, not one.reschedulemoves the date you send forward to the first anchor on or after it.
Read next_billing_at in the receipt for the date that was actually set. It can be later than the
one you sent, and a client that echoes its own input back to a merchant will show them a charge date
that is not going to happen. Rolling contracts are unchanged: the date you send is the date that is set.
The response is a receipt, not the resource
{
"id": "gid://shopify/SubscriptionContract/42",
"contract_id": "gid://shopify/SubscriptionContract/42",
"action": "pause",
"previous_status": "ACTIVE",
"status": "PAUSED",
"next_billing_at": "2026-12-01T00:00:00.000Z",
"line": null
}This is not the row GET /subscriptions/{id} returns, and the omission is deliberate. Scopes
here are exact and write: does not imply read:, so answering a write with a full read payload
would quietly grant a write-only key the read it was never given. Everything in the receipt is a fact
the write itself established.
previous_statusis what the mirror held before the call, so you can tell a real transition from a no-op. Pausing an already-paused contract succeeds and changes nothing, and that is worth detecting without a second request.lineisnullon the five actions and carries the re-read line on aPATCH— so a quantity Shopify clamped, or a title a swap changed, reaches you as what actually happened rather than as what you sent.
A successful write reconciles the mirror before answering, so status and next_billing_at
come from Shopify rather than from what we held a moment ago. If that reconcile fails, the write
still returns 200 carrying the pre-write mirror values: the mutation landed, and telling a
merchant their cancel failed when it did not is the worst lie available on this surface. The
subscription_contracts/update webhook corrects the mirror moments later either way.
What can go wrong
| Code | Status | Cause |
|---|---|---|
not_found | 404 | No contract with that id on this shop — see below |
invalid_request | 400 | A malformed date, a bad variant GID, nothing to change, a missing Idempotency-Key — or a Shopify refusal that names a field you sent |
state_conflict | 409 | Shopify refused because of the contract’s state: already cancelled, not active, nothing to skip |
idempotency_conflict | 409 | See Idempotency |
insufficient_scope | 403 | The key lacks write:subscriptions |
The split between invalid_request and state_conflict is mechanical: a Shopify error that names a
field is about something you sent; one that does not is about the contract. Branch on it — one
means fix your payload, the other means re-read the contract and decide whether you still want to.
Two things these endpoints will not do
They only reach contracts Curobi created. Ownership is proved against Curobi’s local mirror,
which holds a row only for contracts this app created on this shop — never against the id in your
payload. A contract another subscription app created is invisible here and returns the same 404 as
a made-up id. Curobi holds Shopify’s write_own_subscription_contracts scope, so it is not ours to
change. See the scope limit.
They do not emit a second webhook. subscription_contracts/update is the single chokepoint every
status change flows through — the in-app list, the customer portal, Shopify’s native admin, and now
this — and it is where subscription.paused, .cancelled and .reactivated are published. An API
write reconciles Curobi’s mirror and lets that webhook do the announcing, so one change produces
one outbound event. Expect it to arrive after your 200, not before it.
Plans
Scope: read:plans
GET /plans?status=activeReturns each plan with its stored config decoded into both raw fields and the same human labels
the admin and the CSV export render — cadence.label, billing.label, discount.label. You never
have to re-derive “Every month”, and you never derive it differently than we do.
giftable tells you whether the plan accepts gift purchases.
schedule is the plan’s billing calendar. Every plan that bills from each subscriber’s signup date —
including every plan created before fixed-calendar schedules existed — returns:
"schedule": { "mode": "rolling" }A fixed-calendar plan returns "mode": "anchored" with the calendar itself:
"schedule": {
"mode": "anchored",
"anchor_day": 15,
"anchor_months": [2, 5, 8, 11],
"cutoff_days": 3,
"pre_anchor_behavior": "NEXT",
"ship_day": 1,
"label": "Charged on the 15th of Feb, May, Aug and Nov. Ships on the 1st of the following month. Order at least 3 days before a charge date to be included in that shipment."
}| Field | Meaning |
|---|---|
anchor_day | The day of the month every subscriber is charged |
anchor_months | The months it charges in. Empty means the day is fixed but the months follow each subscriber’s own cadence |
cutoff_days | How many days before a charge date an order must arrive to be included in that shipment |
pre_anchor_behavior | What a signup between charge dates gets first: NEXT waits and ships with the next charge date’s batch; ASAP ships right away, then renews on the charge date |
ship_day | The day boxes ship; null when they ship on the charge day |
label | The sentence shoppers see at checkout (the selling plan’s description) |
On an anchored plan, cadence does not tell you how often it bills. cadence.interval is always
"MONTH" and cadence.interval_count is the value stored on the plan form, but a plan with
anchor_months set is sent to Shopify as one year with one anchor per listed month — so a plan
reading “every 1 month” can bill four times a year. Use schedule, or cadence.label, which reads
as the calendar: “4 times a year · Feb, May, Aug and Nov 15”.
Plans are read-only here. A merchant sets a fixed calendar in the Curobi admin, on the Pro plan. A plan
anchored before the merchant moved below Pro still bills on its anchors and still returns
"mode": "anchored", so don’t infer the shop’s plan from it.
Bundles
Scope: read:bundles
GET /bundles
GET /bundles/{id}/boxes?period=2026-08Covers curated, build-a-box and regular plans. /boxes returns the box and its items; without
?period= it returns the most recent 24 periods.
Mix & Match is a separate resource — it reuses the same underlying record but has a fundamentally different money model (no rotating box, no propagation), so folding the two together would produce rows where half the fields are structurally meaningless.
Mix & Match
Scope: read:mixmatch
GET /mix-match
GET /mix-match/{id}The single-plan form returns the full pickable catalog — categories with their entries — plus
cart_group_id, the value every line of one selection must carry as _rc_mm_group
(see the cart-line contract).
price_cents is a snapshot, refreshed on save. The live variant price wins at checkout, and
the discount function re-derives the tier from real cart quantities. Nothing here is a price
promise — don’t quote it to a shopper as final.
Gifts
Scope: read:gifts (+ read:customers for names, emails and messages)
GET /gifts?status=claimedgifter_name, gifter_email, recipient_name, recipient_email and message are null without
read:customers.
kind is either prepaid (the plan ends itself after N charges) or recurring (the gifter’s card
keeps being billed) — the one fact that changes what each party has to be told. Claim tokens are
never returned at any scope.
Win-back
Scope: read:winback
GET /winback/campaigns
GET /winback/campaigns/{id}/sendsThe send ledger carries the attribution outcome: reactivated, reactivated_at,
recovered_amount.
Notifications
Scope: read:notifications
GET /notifications?event= &status= &customer_id=The outbound email log with its full delivery lifecycle. It carries no name or email address, so it needs no PCD companion scope to be useful.
Analytics
Scope: read:analytics
GET /analytics/mrr
GET /analytics/churn?range=7d|30d|90d|12mo
GET /analytics/cohorts?window=12|24|all
GET /analytics/recovery?range=7d|30d|90d|12moThese call the same functions the in-app dashboards call, so the API and the admin can never disagree about what churn means.
/mrr reports contracts_missing_amount alongside the figure. Contracts whose monthly amount has
never been backfilled contribute zero, and anyone reconciling against their own ledger deserves to
know how much of the base the number actually covers. Inventing an average for the unknown rows
would make the figure look better and mean less.
Webhooks
Scope: manage:webhooks
GET /webhooks/endpoints POST /webhooks/endpoints
GET /webhooks/endpoints/{id} PATCH /webhooks/endpoints/{id}
DELETE /webhooks/endpoints/{id}
GET /webhooks/deliveries ?endpoint_id= &status= &event= &cursor= &limit=
GET /webhooks/eventsFull reference: Webhooks. The signing secret is returned once, by
POST /webhooks/endpoints, and by nothing else at any scope — a read key that could fetch signing
secrets would be a read key that can forge our signature to your own receiver.
Exports
Scope: whatever the dataset itself requires — an export needs exactly the scopes that paging the same rows needs.
POST /exports → 202, a job id
GET /exports → recent jobs
GET /exports/{id} → status, and a signed download URL once ready
GET /exports/datasets → the catalogueAsynchronous CSV and JSONL exports of subscriptions, plans, bundles, Mix & Match and the
importer-shaped migration file. Ask, poll, fetch — a multi-minute streaming response is not
something a nightly pull should depend on.
Full reference, including filters, formats, signed download URLs, limits and retention: Export jobs.
Worked examples
The Node, Python and Ruby tabs build on the client set up in the
quickstart — curobi(), session, and BASE.
Count active subscriptions
cURL
curl -sS "https://app.curobi.com/api/admin/v1/subscriptions?status=ACTIVE&limit=100" \
-H "Authorization: Bearer $CUROBI_API_KEY" | jq '.data | length'data.length is the size of this page, not the size of the result set. There is no total
count on a keyset-paginated list — computing one would mean a second full scan that is stale by
the time it returns. To count everything, page to the end as below and count as you go.
Page through everything
The shape worth writing once and reusing: keep requesting until next_cursor is null.
cURL
cursor=""
while :; do
page=$(curl -sS "https://app.curobi.com/api/admin/v1/subscriptions?limit=250&cursor=$cursor" \
-H "Authorization: Bearer $CUROBI_API_KEY")
echo "$page" | jq -c '.data[]'
cursor=$(echo "$page" | jq -r '.next_cursor // empty')
[ -z "$cursor" ] && break
donePull only what changed
The shape most nightly syncs actually want: keep a high-water mark, pass it back as
updated_after, and advance it only after the run succeeds.
cURL
curl -sS "https://app.curobi.com/api/admin/v1/subscriptions?updated_after=2026-08-15T00:00:00Z&limit=250" \
-H "Authorization: Bearer $CUROBI_API_KEY"updated_after takes an ISO 8601 timestamp and an unparseable one is
400 invalid_request, not a silently ignored filter. Stamp the
watermark from when the run started, not when it finished — anything written during the crawl
then falls inside the next window rather than into the gap between the two.
Not in v1
- Plan, bundle, Mix & Match, gift and win-back writes.
write:plans,write:bundles,write:mixmatch,write:giftsandwrite:winbackare published vocabulary rather than working endpoints, and they are not offered when you mint a key — a checkbox for an endpoint that does not exist would mint keys whose stated power is fiction. Creating and editing plans, boxes and campaigns is admin work today. Subscription writes shipped first, and are the only writes on this surface that touch merchant data. - Idempotency anywhere but the subscription writes.
POST /exportsand/webhooks/endpointsaccept the header and ignore it — see the note on retrying endpoint creation.