Storefront API reference (v1)
https://app.curobi.com/api/storefront/v1The browser-safe surface. It is callable from storefront JavaScript, from a Hydrogen loader, or
from curl — authenticated with a publishable key rather than a
secret key, and every endpoint on it returns data that is already public
through Shopify’s own Storefront API.
Nothing here is a write. The one POST computes a price quote for a selection a shopper is
assembling; it changes nothing on the shop.
Why this exists
A headless storefront could already read a bundle’s configuration from a public metafield and write the right cart properties at checkout — that contract is Headless & custom storefronts. Two things had no browser-reachable path at all.
The Mix & Match pickable catalogue. Reachable only behind a secret key, server-side, or through the Shopify App Proxy, which is Online Store only. Every Hydrogen picker therefore needed a hop through the merchant’s own backend. It is proxied rather than inlined in a metafield because a large catalogue exceeds the metafield size ceiling, so another metafield could never have fixed it.
A live price preview. Available nowhere, at any tier. An integrator had to re-implement the Mix & Match tier math in their own codebase and hope it agreed with the checkout function — and when it didn’t, the UI and the charge disagreed, which is the worst failure a bundle builder has.
GET /mix-match/{id} and
POST /mix-match/{id}/price are those two. Everything else on
this surface exists because a picker that can price itself but cannot tell you which selling plan to
add to cart is still a picker you cannot ship.
Authentication
Authorization: Bearer curobi_pk_live_7f3aQ2…Publishable keys only. Mint one on the API keys page in your Curobi admin: Create API key → Browser key.
A curobi_sk_… secret key presented here is refused, with exactly the message an unknown key
gets. A merchant who pastes a server key into storefront JavaScript has published a
server-to-server credential to every visitor, and an endpoint that quietly accepted it would be
why they never noticed.
A publishable key is world-readable by design. It identifies the shop and meters the traffic; it protects nothing. The actual boundary is that every endpoint here returns data already public via Shopify’s own Storefront API.
Scopes
A browser key may hold only these four, and the narrowing happens at mint time, on the stored credential — not per request:
read:plans read:bundles read:mixmatch read:giftsread:customers, read:subscriptions, read:winback, read:notifications, read:analytics and
manage:webhooks cannot be granted to a browser key at all.
There is no Protected Customer Data on this surface, at any scope — not redacted, absent. No name, no email address, no shipping address is reachable with a publishable key however it was minted. That is enforced as an allowlist of what a publishable key may hold, rather than a denylist of what it may not: a scope nobody thought to forbid is still not grantable here. Anything that needs to know who is asking belongs on the Customer API.
Origins
A browser key carries an optional allowlist:
https://shop.example.com
https://*.vercel.app- Compared as origins, not strings: casing, a trailing slash and a default port are all
normalized on both sides, so
https://shop.example.com.evil.testnever matches. *.example.commatches one label deeper and not the apex — a merchant who wantsexample.comlists it.- Empty means any origin. A merchant who has not configured one has not asked for a restriction.
- A request with no
Originheader is allowed. Server-side rendering is a first-class way to call this surface and sends none; refusing it would be enforcing a browser rule against something that is not a browser.
The allowlist is a courtesy, not a security boundary. Anyone holding the key can call these endpoints from a terminal, where CORS has nothing to say. What it stops is a third party’s page spending a merchant’s rate budget in their visitors’ browsers.
CORS
OPTIONS preflights are answered before authentication — a browser sends no Authorization
header on one — with a 10-minute Access-Control-Max-Age.
Responses carry Vary: Origin and expose X-Curobi-Request-Id and the three
X-Curobi-RateLimit-* headers, so a client can slow down before it is refused.
An origin refusal is the one failure that deliberately carries no Access-Control-Allow-Origin.
The browser is supposed to block that read.
Rate limits and the plan gate
600 requests/minute sustained, 900 burst, per shop. A separate bucket from the Admin API, because one shopper opening a picker is several calls.
The same Pro entitlement as the Admin API applies, re-checked per request and cached for at most 60 seconds — so a downgrade takes this surface offline within the minute. See Pricing & Plans. Uninstalling Curobi revokes every key, publishable ones included.
Errors
Identical envelope to the Admin API: a stable code, human message, doc_url and request_id.
Branch on error.code; error.message is human copy and may be reworded. See
API errors.
The selling_plan_id a cart line needs
Read this before you build a cart. Every resource on this surface returns selling_plan_id.
The Admin API returns only selling_plan_group_id, which is not the value a cart
line carries — the line needs the child selling plan. That gap meant an integrator building
against the Admin API alone got all the way to add-to-cart before discovering it.
It is resolved live from Shopify and cached about 60 seconds per shop rather than stored, because Shopify owns the child plan and re-creates it whenever the merchant edits the offer. A stored copy would be right until the first edit and wrong silently thereafter.
selling_plan_id is null when the group has no child plan — a half-built offer — or when the
lookup failed. It is never silently omitted, so null and “absent” never have to be told apart. The
documented fallback is Shopify’s own Storefront API, which returns selling plans natively.
Endpoints
GET /plans
Scope: read:plans
The shop’s active plans. A draft or archived plan is not something a shopper can buy, so it is not returned.
{
"plans": [
{
"id": "clx…",
"name": "Monthly coffee",
"merchant_code": "monthly-coffee",
"selling_plan_group_id": "gid://shopify/SellingPlanGroup/12",
"selling_plan_id": "gid://shopify/SellingPlan/34",
"giftable": true,
"cadence": { "interval": "MONTH", "interval_count": 1, "deliveries_per_charge": 1, "label": "Every month" },
"billing": { "type": "prepaid", "label": "Prepaid" },
"discount": { "type": "percentage", "value": 10, "label": "10% off" },
"min_cycles": null,
"max_cycles": null
}
]
}The label fields are the same human strings the merchant’s admin shows, so a storefront never
re-derives “Every month” from an interval and a count — and never derives it differently than we do.
GET /bundles/{bundle_plan_id}
Scope: read:bundles
One curated or build-a-box plan: its configuration, its selling plan, and its contents.
Contents arrive under different keys on purpose:
| Key | Is | For |
|---|---|---|
box | The shipped fact for the current period | A curated plan |
menu | The set of choices | A build-a-box plan |
A storefront that confuses the two renders a picker for something nobody may pick.
{
"id": "clx…",
"name": "Coffee club",
"box_type": "build_a_box",
"menu_source": "pool",
"purchase_mode": "subscription",
"pricing_mode": "fixed",
"box_min_items": 3,
"box_max_items": 6,
"selection_cutoff_days": 3,
"box_product_id": "gid://shopify/Product/7",
"selling_plan_group_id": "gid://shopify/SellingPlanGroup/12",
"selling_plan_id": "gid://shopify/SellingPlan/34",
"pick_property": "_rc_box_pick",
"menu": {
"period": "pool",
"is_pool": true,
"min_items": 3,
"max_items": 6,
"items": [
{ "variant_id": "gid://shopify/ProductVariant/1", "product_id": "gid://shopify/Product/1",
"product_title": "Ethiopia Yirgacheffe", "variant_title": "250g", "image_url": "https://…",
"quantity": 1 }
]
}
}Only published boxes are returned. A draft box is next month being built, and a storefront that rendered it would be spoiling a box nobody has been charged for.
pick_property is the cart-line key the shopper’s selection must ride at checkout — the same
_rc_box_pick contract documented under
cart-line properties.
The "pool" period is a sentinel, not a date
A build-a-box plan whose menu_source is "pool" keeps one stable menu that applies to every
cycle, rather than a fresh "YYYY-MM" box each month. That menu is stored under the literal period
"pool".
Do not parse period as a date. Branch on is_pool, which this surface returns explicitly
for exactly this reason — and menu_source on the plan tells you the same thing before you have
fetched a menu at all. A period of "pool" with is_pool: true is a correct value, not
malformed data, and a Date parser that throws on it will take down a working picker.
GET /mix-match/{bundle_plan_id}
Scope: read:mixmatch
The endpoint that removes the server hop. The plan’s tiers, bounds, cart grouping, and a page of its pickable catalogue.
GET /mix-match/{bundle_plan_id}?category= &q= &page=categoryfilters to one category id.qis a case-insensitive title search.pageis 0-based.
{
"id": "clx…",
"name": "Build your six-pack",
"box_min_items": 3,
"box_max_items": 6,
"purchase_mode": "subscription",
"selling_plan_group_id": "gid://shopify/SellingPlanGroup/12",
"selling_plan_id": "gid://shopify/SellingPlan/34",
"cart_group_id": "clx…",
"group_property": "_rc_mm_group",
"tiers": [ { "min_quantity": 3, "discount_type": "percentage", "discount_value": 10 } ],
"categories": [ { "id": "cat-1", "title": "Hoppy" } ],
"entries": [
{ "variant_id": "gid://shopify/ProductVariant/1", "product_id": "gid://shopify/Product/1",
"product_title": "IPA", "variant_title": "330ml", "image_url": "https://…",
"price_cents": 450, "category_id": "cat-1", "sold_out": false }
],
"page": 0,
"has_more": true
}sold_out is a live Shopify availability check on the returned page, so a headless picker greys
out what a themed one already did instead of a shopper discovering the failure at add-to-cart. It is
null when the sweep could not run — best effort, never a reason to fail the catalogue.
price_cents is the stored snapshot, refreshed when the merchant saves. The live variant price
still wins at checkout. Nothing on this surface is a price promise.
Only a sellable plan is returned — linked to a selling plan, or a one-time plan with a box
product, and not archived — so a draft never leaks to a storefront the day before launch. Anything
else is a 404 not_found.
Cached public, max-age=60, the same window the App Proxy route uses, so a merchant’s edit reaches
shoppers within the same beat on either surface.
cURL
curl -sS "https://app.curobi.com/api/storefront/v1/mix-match/clx8f2k9a0001?page=0" \
-H "Authorization: Bearer $CUROBI_PUBLISHABLE_KEY"POST /mix-match/{bundle_plan_id}/price
Scope: read:mixmatch
The live price preview. The client sends variant ids and quantities and nothing else.
cURL
curl -sS -X POST "https://app.curobi.com/api/storefront/v1/mix-match/clx8f2k9a0001/price" \
-H "Authorization: Bearer $CUROBI_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{"items":[{"variant_id":"gid://shopify/ProductVariant/1","quantity":2}]}'Prices come from the merchant’s stored catalogue, tiers from their saved configuration, and the arithmetic is the same pure function the storefront widget and our own tests use — which mirrors the per-line rounding Shopify’s discount function performs at checkout.
A client that sent its own prices could be told any total it liked. A client that computed its own total would disagree with the function by a cent on odd-cent catalogues across multiple lines.
{
"id": "clx…",
"cart_group_id": "clx…",
"group_property": "_rc_mm_group",
"currency_note": "All amounts are integer cents in the shop's own currency.",
"total_quantity": 6,
"subtotal_cents": 2700,
"discount_cents": 540,
"total_cents": 2160,
"tier": { "min_quantity": 6, "discount_type": "percentage", "discount_value": 20 },
"next_tier": null,
"items_to_next_tier": 0,
"valid": true,
"validation_message": null,
"box_min_items": 3,
"box_max_items": 6,
"lines": [
{ "variant_id": "gid://shopify/ProductVariant/1", "product_id": "gid://shopify/Product/1",
"product_title": "IPA", "variant_title": "330ml", "image_url": "https://…",
"category_id": "cat-1", "quantity": 2, "unit_price_cents": 450, "line_subtotal_cents": 900 }
],
"unknown_variant_ids": [],
"tiers": [ { "min_quantity": 3, "discount_type": "percentage", "discount_value": 10 } ]
}Behaviours worth knowing before you build against it:
| Situation | What happens | Why |
|---|---|---|
| Two lines for the same variant | Collapsed into one, quantities summed | One cart line at checkout; pricing them separately would round twice and drift from the charge |
| A variant not in this plan’s catalogue | Listed in unknown_variant_ids, excluded from the quote | Dropping silently would quote a cart the shopper isn’t building; a 400 would break a picker the moment a merchant removes an entry mid-session |
Below box_min_items or above box_max_items | Still priced, valid: false with a validation_message | A picker shows a running total below the minimum — it just cannot check out |
Empty items | total_cents: 0, valid: false | The opening state of every picker |
items is not an array, or more than 200 lines | 400 invalid_request | A picker has tens of choices, not thousands |
Never cached (no-store): a quote is a function of one shopper’s selection.
A quote is not a price promise. The live variant price wins at checkout, and the discount function re-derives the tier from real cart quantities. What this endpoint gives you is what the shopper should be shown, computed the way the charge will be computed.
GET /gifts/terms/{selling_plan_id}
Scope: read:gifts
A plan’s gift terms plus the finished disclosure sentence, so a headless “This is a gift” panel renders text rather than assembling copy that could drift from ours.
{selling_plan_id} is Shopify’s selling-plan id, numeric or GID — what a storefront actually has in
hand when it renders the panel.
{
"kind": "recurring",
"cycles": null,
"giftable": true,
"disclosure": "You'll keep being charged every month until you cancel.",
"resolved": true
}Every failure path returns the conservative recurring disclosure, with resolved: false,
rather than an error. Over-warning costs a shopper a moment’s attention; under-warning costs them
money they did not plan to spend. Rendering nothing on a failure is the one behaviour to avoid —
and because this endpoint already falls back that way, rendering whatever disclosure you were
handed is the correct client behaviour in every case.
Not on this surface
| Want | Where it lives |
|---|---|
| A customer’s own subscriptions | Customer API |
| Changing a build-a-box pick after purchase | Customer API |
| Any name, email address or shipping address | Nowhere on a browser key, at any scope. read:customers on a server key, and that access is logged |
| Writes of any kind | The Admin API, server-side, on a secret key |