Customer API reference (v1)
https://app.curobi.com/api/customer/v1The 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
| Header | Carries | Proves |
|---|---|---|
Authorization: Bearer curobi_pk_… | The merchant’s publishable key | Which shop. |
X-Curobi-Session: curobi_cst_… | A Curobi session token | Which 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
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
| Endpoint | What it is for | |
|---|---|---|
GET | /subscriptions | The session’s own subscriptions, as a summary |
GET | /config | The merchant’s portal settings |
GET POST | /subscriptions/{contract_id}/box | Read and change the build-a-box pick |
GET | /subscriptions/{contract_id}/payment-method | The card behind the contract |
POST | /subscriptions/{contract_id}/payment-method/update-email | Email a secure update link |
GET | /subscriptions/{contract_id}/lines/{line_id}/swap-options | Variants this line may swap to |
POST | /subscriptions/{contract_id}/lines/{line_id} | Change quantity and/or swap the variant |
POST | /subscriptions/{contract_id}/reschedule | Move the next charge |
POST | /subscriptions/{contract_id}/cancel-reason | Why the customer is leaving |
POST | /subscriptions/{contract_id}/save-event | A retention offer was shown, or accepted |
GET POST | /notification-preferences | The customer’s opt-outs |
GET | /notification-activity | The 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" } }| Failure | Code | Status |
|---|---|---|
| Editing has closed for the next box, or the menu isn’t published yet | selection_closed | 409 |
| Not a build-a-box plan, or the pick fails the menu / size check | invalid_request | 400 |
| The contract belongs to someone else | forbidden | 403 |
| No such contract on this shop | not_found | 404 |
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.
| Failure | Code | Status |
|---|---|---|
| No saved payment method on this contract | invalid_request | 400 |
| Shopify refused the send | internal_error | 500 |
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 }| Failure | Code | Status |
|---|---|---|
| No such line on this contract | not_found | 404 |
| Neither field sent, or a quantity below 1 or not a whole number | invalid_request | 400 |
| Shopify refused the edit (the message is Shopify’s) | invalid_request | 400 |
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:
| Action | Shopify mutation |
|---|---|
| Pause | subscriptionContractPause |
| Resume | subscriptionContractActivate |
| Cancel | subscriptionContractCancel |
| Skip a cycle | subscriptionBillingCycleSkip |
| Edit the delivery address | subscriptionContractFetchDeliveryOptions → subscriptionContractSelectDeliveryMethod |
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.