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 APICustomer API Reference

Customer API reference (v1)

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

The backend for a headless customer portal. A storefront exchanges the shopper’s Shopify Customer Account token for a short-lived Curobi session, then runs subscription management on its own account pages instead of sending subscribers to Shopify-hosted customer accounts.

Five portal actions are deliberately not here — see What is deliberately not here before you plan a build against it, because the answer changes what your storefront has to call.

Why this exists

Curobi’s customer-facing writes sit behind a session token issued to a customer-account UI extension — a credential that only exists inside Shopify-hosted account pages. A Hydrogen storefront running its own account pages holds something else entirely: a Customer Account API access token from its own OAuth flow. The two are not interchangeable, so the gap was never merely CORS.

The consequence was specific and severe. A headless merchant could already sell a build-a-box subscription — the menu is readable from a public metafield, the pick rides _rc_box_pick at checkout — and then had no way to let the subscriber change next month’s box. A subscriber who cannot change next month’s box has a curated box with extra steps, so “yes to sell, no to manage” made build-a-box unshippable on a headless storefront.

This surface is the second door.

Authentication — two credentials, both required

HeaderCarriesProves
Authorization: Bearer curobi_pk_…The merchant’s publishable keyWhich shop.
X-Curobi-Session: curobi_cst_…A Curobi session tokenWhich customer.

Neither is sufficient on its own. The publishable key is public by design and confers no authority over customer data; the session token is minted only in exchange for a live Shopify customer token.

POST /session

The one endpoint that runs without a session — it is the endpoint that issues one.

curl -sS -X POST "https://app.curobi.com/api/customer/v1/session" \ -H "Authorization: Bearer $CUROBI_PUBLISHABLE_KEY" \ -H "Content-Type: application/json" \ -d '{"customer_access_token":"<the shopper'\''s Shopify Customer Account API access token>"}'
{ "session_token": "curobi_cst_…", "expires_at": "2026-09-04T12:30:00.000Z", "expires_in": 1800, "customer_id": "gid://shopify/Customer/1", "session_header": "X-Curobi-Session" }

We do not parse, decode or trust the Shopify token — we spend it. One query against Shopify’s Customer Account API asking { customer { id } }. Shopify answers with the id of whoever that token belongs to, or refuses. The correctness of this does not depend on us understanding Shopify’s token format, which is the property that matters, because that format is theirs to change.

Every refusal collapses to one 401 invalid_credential with one message. “No such customer”, “wrong shop” and “expired an hour ago” are deliberately indistinguishable — an authentication endpoint that answers questions is an authentication endpoint that enumerates.

Sessions last 30 minutes. Long enough that a customer editing next month’s box never has a save fail underneath them; short enough that a token scraped out of a proxy log is worthless by the time anyone reads it. Refresh by calling POST /session again with the Shopify token your storefront already holds — there is no refresh-token machinery.

A session token is a stateless signed claim, not a server-side session: there is no session table and nothing to invalidate. It is bounded twice — by its lifetime, and by the fact that every request it authorizes re-verifies ownership of the specific contract against Shopify anyway.

What a session cannot survive

  • Key revocation. The publishable key is re-resolved on every request, so revoking it kills every session minted under it within one request — which is what a merchant means when they click Revoke.
  • Cross-shop presentation. A session names its own shop, checked against the key’s shop on every request. This closes the one cross-tenant hole a stateless token makes possible.
  • Secret rotation. Rotating the server-side pepper Curobi hashes credentials with invalidates every live session. A legitimate break-glass action; not a routine one.

Rate limits, plan gate, CORS

120 requests/minute sustained, 180 burst, per shop — its own bucket, sized for a human clicking rather than a machine crawling. The same Pro entitlement as the rest of the Developer API applies and is re-checked per request; see Pricing & Plans.

Origin allowlists and preflight behaviour are identical to the Storefront API.

Every response is Cache-Control: no-store. It is one customer’s own data, and nothing anywhere should keep it — not a CDN, not a service worker, not your own edge cache.

Every failure carries the standard error envelope with the request’s CORS and rate-limit headers, so a browser can actually read the 403 it was given. An error fetch cannot see is an error an integrator debugs as a network fault.

Where authorization actually happens

Not in the middleware. The middleware establishes who.

Every endpoint that touches a contract re-asks Shopify whether that customer owns that contract, before any read or mutation. The check lives where the contract is loaded rather than at the route boundary — so skipping it means having no contract to act on. There is no route-level check a new endpoint could forget to copy.

The local mirror is never the authority for a write. GET /subscriptions lists from it because a list is a list; everything that acts on one contract goes through Shopify.

There is one exception, covering the two save-flow analytics writes, and it goes through the mirror rather than through nothing — see why.

Endpoints

EndpointWhat it is for
GET/subscriptionsThe session’s own subscriptions, as a summary
GET/configThe merchant’s portal settings
GET POST/subscriptions/{contract_id}/boxRead and change the build-a-box pick
GET/subscriptions/{contract_id}/payment-methodThe card behind the contract
POST/subscriptions/{contract_id}/payment-method/update-emailEmail a secure update link
GET/subscriptions/{contract_id}/lines/{line_id}/swap-optionsVariants this line may swap to
POST/subscriptions/{contract_id}/lines/{line_id}Change quantity and/or swap the variant
POST/subscriptions/{contract_id}/rescheduleMove the next charge
POST/subscriptions/{contract_id}/cancel-reasonWhy the customer is leaving
POST/subscriptions/{contract_id}/save-eventA retention offer was shown, or accepted
GET POST/notification-preferencesThe customer’s opt-outs
GET/notification-activityThe customer’s own recent mail

{contract_id} is the Shopify SubscriptionContract GID, URL-encoded; {line_id} likewise.

GET /subscriptions

The session’s own subscriptions. Scoped by the customer id from the verified session — there is no parameter a caller can supply that changes whose subscriptions come back.

{ "subscriptions": [ { "contract_id": "gid://shopify/SubscriptionContract/5", "status": "ACTIVE", "order_name": "#1014", "product_title": "Coffee club", "frequency_label": "Every month", "next_billing_at": "2026-10-01T00:00:00.000Z", "charge_amount": 42.0, "currency_code": "USD", "selling_plan_id": "gid://shopify/SellingPlan/34", "selling_plan_group_id": "gid://shopify/SellingPlanGroup/12", "created_at": "2026-08-01T00:00:00.000Z" } ] }

GET /subscriptions/{contract_id}/box

Is this a bundle subscription, and what is in the next box? For a build-a-box plan this also returns the period’s menu, the customer’s current pick, and whether editing is still open — everything a headless picker needs to render itself in one call.

The response is the same box object the Shopify-hosted portal renders, so the two surfaces cannot drift into showing a customer different things.

POST /subscriptions/{contract_id}/box

Save the customer’s build-a-box pick for their upcoming order. This is the intent whose absence made a whole product mode unsellable headlessly rather than merely less convenient.

POST /api/customer/v1/subscriptions/gid%3A%2F%2Fshopify%2FSubscriptionContract%2F5/box Authorization: Bearer curobi_pk_live_… X-Curobi-Session: curobi_cst_… Content-Type: application/json { "items": [ { "variant_id": "gid://shopify/ProductVariant/1", "quantity": 2 } ] }

The client sends variant ids and quantities and nothing else. Titles, images and prices are re-derived server-side from the published menu. Three guards are enforced where the data is written, not in the picker:

The contract must subscribe to a build-a-box plan

The pick must be within the cutoff

selection_cutoff_days before billing — the plan’s own window, readable from GET /bundles/{id} on the Storefront API.

The pick must be valid against the menu and the box-size bounds

A headless picker cannot talk its way past any of them by sending a nicer payload.

On success the pick is applied to the contract immediately — the same draft, edit and commit mechanism the propagation engine uses — so the change is reflected without waiting for a scheduled job.

{ "ok": true, "box": { "…": "the same shape GET returns" } }
FailureCodeStatus
Editing has closed for the next box, or the menu isn’t published yetselection_closed409
Not a build-a-box plan, or the pick fails the menu / size checkinvalid_request400
The contract belongs to someone elseforbidden403
No such contract on this shopnot_found404

selection_closed is its own code because the request was fine — it is the timing that was wrong. Branch on it by telling the customer when they can edit again, not by fixing your payload.

GET /config

The merchant’s portal settings. Contract-independent — the session is the only gate it needs.

{ "reschedule_max_months": 3, "cancel_flow": { "offer_pause": true, "offer_skip": true, "show_reason_survey": false, "reasons": ["Too expensive", "Not using it enough", "Other"] }, "portal_actions": { "hide_skip": false, "hide_pause": false, "hide_cancel": false, "hide_edit_address": false, "hide_edit_quantity": false, "hide_swap_product": false, "hide_change_date": false } }

portal_actions is a HIDE list. Every action is allowed unless the merchant turned it off, which mirrors how the settings are stored. A storefront that inverted it would show a customer an action the merchant had disabled.

Read this rather than hard-coding your own offers and window. A fork that decides its own retention offers looks like Curobi right up until the merchant changes a setting, at which point the Shopify-hosted portal and yours disagree about what the shop offers.

GET and POST /subscriptions/{contract_id}/payment-method

GET returns the card behind the contract, or null — a manual or invoice contract has no vaulted method, and that is an answer, not an error.

{ "payment_method": { "kind": "card", "brand": "visa", "last4": "4242", "expiry_month": 4, "expiry_year": 2030, "paypal_email": null, "expiry_state": "expiring_soon" } }

The CustomerPaymentMethod id is deliberately absent. It is the argument the update mutation takes, and handing it to a browser would let a page act on a payment method by id. A portal needs to show the card and ask for an update; neither needs the identifier.

POST /subscriptions/{contract_id}/payment-method/update-email asks Shopify to email the shopper a secure update link, and re-reads the payment method server-side rather than accepting one.

{ "ok": true }

There is no card form on this surface, or on any other. The update page is hosted by Shopify. Collecting card details in a storefront to save a click would move the merchant into PCI scope.

FailureCodeStatus
No saved payment method on this contractinvalid_request400
Shopify refused the sendinternal_error500

GET /subscriptions/{contract_id}/lines/{line_id}/swap-options

The variants this line may swap to, bounded to the selling-plan group the line already subscribes under — so the line’s existing selling plan stays valid and a swap cannot re-negotiate the price.

{ "options": [ { "variant_id": "gid://shopify/ProductVariant/2", "variant_title": "500g", "product_title": "Kenya AA", "image_url": null, "price": "18.00", "available": true } ] }

An empty list is a real answer — this line has nowhere to go — never a 404. Collapsing the two sends an integrator looking for a bug in their line id.

Where {line_id} comes from

Not from here. GET /subscriptions is a summary; a contract’s lines come from Shopify’s own Customer Account API, which your storefront is already querying for status, price and delivery address:

subscriptionContract(id: $id) { lines(first: 20) { edges { node { id title quantity } } } }

That split is deliberate rather than an omission. Shopify owns the contract, so re-serving its lines from our mirror would create a second answer to a question that already has one — and the two would disagree the moment a webhook lagged.

POST /subscriptions/{contract_id}/lines/{line_id}

Change a line’s quantity, swap its variant, or both.

{ "quantity": 2, "product_variant_id": "gid://shopify/ProductVariant/2" }

Both fields are optional and at least one is required: a request that names a line and asks for nothing is a client bug worth a 400, not a successful no-op that looks like a save in a UI.

The edit runs through Shopify’s draft, update and commit flow, and a draft that commits with user errors is reported as a failure. There is no partial success on a contract line.

{ "ok": true }
FailureCodeStatus
No such line on this contractnot_found404
Neither field sent, or a quantity below 1 or not a whole numberinvalid_request400
Shopify refused the edit (the message is Shopify’s)invalid_request400

POST /subscriptions/{contract_id}/reschedule

Move the next charge to an explicit date, distinct from a one-click skip.

{ "date": "2026-10-14" }
{ "ok": true, "next_billing_at": "2026-10-14T12:00:00.000Z" }

The merchant’s window is re-checked here, not in your picker. A date picker enforcing the same range is a courtesy to the shopper; a crafted POST carries any date it likes, and on a public REST surface that POST is trivially easy to write. Bound your input anyway — refusing a date you offered is a worse experience than never offering it. The window is reschedule_max_months from GET /config.

Dates are anchored at noon UTC. Midnight would render as the previous calendar day in negative-offset timezones, so a shopper in California would move their order to the 14th and see the 13th.

POST /subscriptions/{contract_id}/cancel-reason and /save-event

The cancellation save flow’s two analytics writes.

POST …/cancel-reason { "reason": "Too expensive" } POST …/save-event { "offer_type": "pause", "event_type": "accepted" }

offer_type is one of pause, skip or discount. event_type is shown or accepted, and anything unrecognised is treated as shown — defaulting the other way would inflate the accepted count, and the revenue attributed to it, on a typo in a client.

Send a shown alongside every accepted. Without the impression, the ledger measures how often an offer worked and never how often it was tried: a conversion rate with no denominator.

The preserved revenue is derived server-side from the local mirror. A client that could state what it saved could state any number, and this ledger is what a merchant’s retention reporting is built on.

Why these two verify ownership differently

Every other contract-scoped endpoint here loads the contract through the ownership check that refuses a CANCELLED one — correctly, because it is the gate for edits. But a cancel reason is recorded around the moment of cancellation, and on a REST surface we do not control whether your storefront writes the reason before or after it calls Shopify’s cancel mutation. Gating these on editability would make that ordering load-bearing and invisible, and would silently lose every reason from a portal that cancelled first.

So these two check the local mirror — shop, contract and customer — instead. It answers “is this the caller’s contract” without answering “may it be edited”, and it fails closed: no row, no write.

Both are best-effort: a failed insert still returns ok. This call sits directly in front of a cancellation the customer has already decided on, and blocking a cancel on an analytics write would be a dark pattern implemented by accident.

GET and POST /notification-preferences

Per-customer, keyed by the session’s own id — no contract, no ownership round-trip.

{ "categories": [ { "key": "reminders", "label": "Renewal reminders", "description": "A heads-up before your next order is charged." } ], "values": { "reminders": true, "orders": true, "box": true, "winback": false } }

POST takes a full map, not a patch — a patch of booleans cannot distinguish “turn this back on” from “don’t mention it” — and answers with the same shape plus "ok": true.

{ "preferences": { "reminders": false, "orders": true } }

Essential account and billing mail is not in categories, and the server refuses to store an opt-out for it. Unknown keys are dropped rather than rejected. Do not add a toggle for essential mail in your own UI: a control that appears to silence a dunning email and does not is worse than no control — and one that actually did would leave a customer’s subscription failing in silence.

GET /notification-activity

The customer’s own recent mail, newest first, capped.

{ "activity": [ { "id": "n1", "title": "Your order is on the way", "status": "delivered", "occurred_at": "2026-09-01T00:00:00.000Z" } ] }

Read-only and best-effort by construction — the underlying query swallows its own failures and returns an empty feed, because an activity list that returns a 500 takes a portal page down over the least important panel on it.

What is deliberately not here

Five things a portal needs are absent on purpose, because Shopify’s own Customer Account API already exposes them to the shopper’s token — the same token your storefront exchanged for a Curobi session:

ActionShopify mutation
PausesubscriptionContractPause
ResumesubscriptionContractActivate
CancelsubscriptionContractCancel
Skip a cyclesubscriptionBillingCycleSkip
Edit the delivery addresssubscriptionContractFetchDeliveryOptionssubscriptionContractSelectDeliveryMethod

These are exactly what Curobi’s own Shopify-hosted portal calls, and a headless storefront calls them the same way. Proxying them through Curobi would add a hop, a second place for them to be wrong, and nothing else. Everything on this surface is something Shopify’s API cannot do: it is Curobi’s own state.

The division of labour, in one line: Shopify owns the contract; Curobi owns the box, the tiers, the window, the save flow and the mail.

The contract’s own fields

Status, line items, prices and the delivery address come from Shopify too. GET /subscriptions is a summary — enough to list and route — deliberately not a second serializer of a contract Shopify already answers for. Query Shopify for the detail view; use this surface for the parts Shopify has no concept of.

Gift recipients

A separate matter, and not solved here. Shopify has no third-party-payer concept, so a recipient gets a tokenized claim page rather than portal access, at any tier — see the two limits that will not be lifted.

Last updated on