Admin API reference (v1)
https://app.curobi.com/api/admin/v1Server-to-server, JSON in and JSON out, authenticated with a
secret key. Read-only, with two exceptions: webhook endpoint management
under manage:webhooks, and POST /exports, which queues a read rather than writing anything.
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.
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.
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.
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.
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
- Subscription writes — pause, resume, cancel, reschedule, line edits. Reads shipped first so the auth layer is exercised by traffic that can’t damage anything before it carries traffic that can.
- Idempotency keys. Not yet honoured — see the note on retrying endpoint creation.
- The Storefront and Customer surfaces — the browser-safe publishable-key API and a headless customer portal. What a headless store can do today is in Headless & custom storefronts.