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

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.

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.

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.

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 quickstartcurobi(), 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

  • 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.
Last updated on