Skip to Content
Your subscription data now goes where you need it: the Curobi Developer API feeds your warehouse, ERP or CRM, and webhooks tell you the moment something changes — read the API docs →
Developer APIAdmin API Reference

Admin API reference (v1)

https://app.curobi.com/api/admin/v1

Server-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.

  1. 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 mandatory Idempotency-Key, a tenancy check against contracts Curobi created, and a receipt rather than the resource.
  2. 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.
  3. 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 crawl
  • limit defaults 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_cursor is null.

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-6f2c5b7e1d90

Any 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.

SituationWhat happens
First request under the keyRuns, and the outcome is stored
Retry, same key, same requestThe first outcome is returned verbatim, with Idempotency-Replayed: true. Nothing runs again
Retry, same key, different request409 idempotency_conflict. A key identifies one request — use a new one
Retry while the first is still in flight409 idempotency_conflict. Wait for the first to answer
The first request failedThe 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: 1755345600

A 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 returns selling_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_id matches selling_plan_group_id — the join key the Plans CSV export calls “Plan group ID”.
  • updated_after takes an ISO 8601 timestamp. An unparseable value is 400 invalid_request rather than being ignored, because silently dropping a filter returns more data than was asked for.
  • The single-contract form additionally carries a gift object when the contract is a gift, and null when 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

EndpointEffect
pauseBilling stops until the contract is resumed
resumeA paused or failed contract goes back to active
cancelTerminal. The customer is no longer billed
skipThe next charge is pushed forward by exactly one billing interval, or to the next charge date on a fixed-calendar plan
rescheduleThe 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:

  • skip moves it to the next anchor, not one interval later. On a quarterly plan that is three months, not one.
  • reschedule moves 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_status is 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.
  • line is null on the five actions and carries the re-read line on a PATCH — 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

CodeStatusCause
not_found404No contract with that id on this shop — see below
invalid_request400A malformed date, a bad variant GID, nothing to change, a missing Idempotency-Key — or a Shopify refusal that names a field you sent
state_conflict409Shopify refused because of the contract’s state: already cancelled, not active, nothing to skip
idempotency_conflict409See Idempotency
insufficient_scope403The 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=active

Returns 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." }
FieldMeaning
anchor_dayThe day of the month every subscriber is charged
anchor_monthsThe months it charges in. Empty means the day is fixed but the months follow each subscriber’s own cadence
cutoff_daysHow many days before a charge date an order must arrive to be included in that shipment
pre_anchor_behaviorWhat 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_dayThe day boxes ship; null when they ship on the charge day
labelThe 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-08

Covers 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=claimed

gifter_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}/sends

The 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|12mo

These 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/events

Full 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 catalogue

Asynchronous 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 -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.

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 done

Pull 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 -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:gifts and write:winback are 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 /exports and /webhooks/endpoints accept the header and ignore it — see the note on retrying endpoint creation.
Last updated on