Storefront API
The Storefront API lets a partner-built storefront act for a signed-in buyer of one ARC seller. Your server calls it. It is server-to-server only: ARC's CORS list does not include storefront origins, so a browser cannot call it directly.
The first version is 2027-01.
POST https://<tenant host>/api/storefront/2027-01/graphql
Before you start
- Your app is installed in the seller's organization with the
read_storefrontscope. That installation gives you a storefront access token. Only a seller user who may install apps (thesettings:apps:installpermission) can grantread_storefront. - The seller's plan has the Storefront API switched on (the
storefrontApiplan feature). It is off by default; ARC turns it on per organization. - The organization comes only from your token. The host you call,
HostandX-Original-Hostplay no part. A token for one seller never reads another seller's data.
Two credentials on every buyer request
Every query and mutation acts for one signed-in buyer, so each request carries two tokens:
| Header | Holds | Lifetime |
|---|---|---|
Authorization: Bearer <storefront access token> | Your installation | Until the installation is uninstalled, paused or the token is revoked |
X-Storefront-Buyer-Token: <buyer access token> | The buyer | 1 hour; refresh it |
A request without a valid buyer token is refused with 401. A buyer token works only with the
installation that signed the buyer in.
Sign a buyer in
The buyer endpoints take your storefront access token in Authorization: Bearer and nothing else.
| Endpoint | Body | Returns |
|---|---|---|
POST /api/storefront/auth/sign-in-code | { "identifier": "<buyer email or phone>" } | Sends the buyer a one-time code |
POST /api/storefront/auth/sign-in | { "identifier": "…", "code": "123456" } | { accessToken, refreshToken, tokenType, expiresIn, buyer } |
POST /api/storefront/auth/refresh | { "refreshToken": "…" } | A new token pair |
POST /api/storefront/auth/sign-out | { "refreshToken": "…" } | { signedOut } |
Keep both buyer tokens on your server, in the buyer's session. An access token can stay valid for up to one hour after sign-out.
What 2027-01 exposes
The schema is a subset of the buyer schema. The exact schema for the version is published as
docs/api/storefront/2027-01.graphql.
Introspection is not available on this path; use the published file.
| Area | Queries | Mutations |
|---|---|---|
| Catalog and stock | customerProducts, customerProductFacets, customerProductDetail, customerProductBySku, customerCategories, customerCategoryChildren, customerBrands | — |
| Buyer pricing | customerMyVariantPrice | — |
| Cart | customerCart, customerCartSummary, customerCartBill | addToCart, updateCartItem, removeCartItem, clearCart, applyDiscountCode |
| Order history | customerOrders, customerOrderDetail | — |
| Quotes | customerQuotes, customerQuoteDetail | customerRequestQuoteFromCart |
| Addresses | customerAddresses | — |
| Checkout | — | placeOrderFromCart |
See Checkout for what placeOrderFromCart accepts.
Anything else is refused with 403 and extensions.code: "FORBIDDEN", before any data is read. That
includes every console operation, every other buyer operation and __schema / __type.
No root takes an organization, partner or customer ID. The buyer is always the one in
X-Storefront-Buyer-Token.
Money
Money is never a JSON number. Every amount comes back as an AppMoney object:
type AppMoney {
amount: String! # a decimal string, e.g. "1234.50"
currencyCode: String! # ISO 4217, e.g. "INR"
}
Each money value has a <field>Money field beside the older number field. Select the …Money field. The
number field is refused on this path.
query Bill {
customerCartBill {
itemCount
subtotalMoney { amount currencyCode }
taxAmountMoney { amount currencyCode }
deliveryFeeMoney { amount currencyCode }
grandTotalMoney { amount currencyCode }
lines {
variantId
quantity
unitPriceMoney { amount currencyCode }
lineTotalMoney { amount currencyCode }
}
}
}
Where prices come from:
- A variant's price for this buyer:
customerMyVariantPrice→finalPriceMoney,listUnitPriceMoney. These come from ARC's pricing engine for that buyer and quantity. Anullmeans there is no price in the requested currency. - The cart:
customerCartBillre-prices every line on each read. Show this bill, not your own sum. - Orders and quotes: the amounts stored on the document, in the document's currency.
Catalog listing prices (CustomerProduct.price and similar) are not served on 2027-01. Ask
customerMyVariantPrice or the cart bill for the buyer's price.
If the seller has not set a base currency, money fields fail with CURRENCY_NOT_CONFIGURED rather than
guessing one.
Quantities, percentages, tax rates and conversion factors stay numbers.
Checkout
placeOrderFromCart places an order from the signed-in buyer's cart. It is the same checkout the
customer portal uses: ARC re-prices every line, applies tax and the delivery charge, and runs the stock,
credit-limit and approval rules before it creates the order. You never send a price or a total.
mutation Checkout($input: PlaceOrderFromCartInput!) {
placeOrderFromCart(input: $input) {
orderId
orderNumber
status
totalAmountMoney { amount currencyCode }
approvalRequest {
requestNumber
status
totalAmountMoney { amount currencyCode }
}
}
}
The input fields you may send:
| Field | Notes |
|---|---|
addressId | Required. The delivery address. It must be an active address of the signed-in buyer's account. Any other address is refused with ADDRESS_NOT_FOUND. Read the buyer's address ids from customerAddresses. |
idempotencyKey | Recommended. Send a new key per checkout attempt. A retry with the same key returns the order already placed, not a second order. |
expectedRevision | The cart revision you showed the buyer. If the cart changed since, checkout is refused with CART_REVISION_CONFLICT. |
notes, reference, poNumber | Free text that is stored on the order. |
paymentMethod, deliveryOptionId and customFields are refused on 2027-01 with 403. Every
storefront order is placed on account, on the buyer's own payment terms, exactly as when a buyer
chooses "On account" in the customer portal. So ARC first checks that the buyer may pay on account: if
the seller does not offer the buyer's payment term as a credit term, checkout is refused with
ON_ACCOUNT_UNAVAILABLE and no order is created. The credit limit then applies as usual. Online
payment is not available on this version.
What can come back:
- An order:
orderId,orderNumber,statusandtotalAmountMoneyare set.statusis usuallyCONFIRMED. - An approval request: when the seller requires approval for this order, no order is created yet.
orderIdisnullandapprovalRequestis set. The cart stays as it was. - A refusal: a GraphQL error, usually with a machine code in
extensions.code, for exampleADDRESS_NOT_FOUND,CART_REVISION_CONFLICT,ON_ACCOUNT_UNAVAILABLEorOVER_CREDIT_LIMIT. A stock shortfall is refused with a message naming the item. Do not retry a refusal with the same input.
On success the cart is emptied.
Pick the delivery address
customerAddresses lists the addresses the signed-in buyer may order to. It takes no argument. A buyer
whose account owner assigned them some addresses sees only those.
query Addresses {
customerAddresses { id label type isDefault isDualPurpose }
}
id, label, type, isDefault and isDualPurpose are always available, so you can let the buyer
pick an address without holding contact data. The street, city, state, postal code, country, contact
name and phone, delivery notes and map position need read_customer_pii (see below). The list is
read-only on 2027-01.
Contact data and custom fields
- Addresses on orders (
shippingAddress,billingAddress) are contact data. They need theread_customer_piiscope on your installation; without it the field is refused. - On
customerAddresses, every location and contact field needsread_customer_piitoo. Onlyid,label,type,isDefaultandisDualPurposeare served without it. customFieldsvalues are not served on2027-01, and the cart, quote and checkout mutations do not accept them.
Limits
Limits fail closed: if ARC cannot check them, the request is refused.
| Limit | Value | Refusal |
|---|---|---|
| Cost of one operation | 1000 points | 400, MAX_COST_EXCEEDED |
| Your installation's storefront bucket | 1000 points, refills 50 per second | 429, THROTTLED, with retryAfterSeconds |
| Limiter unavailable | — | 503, RATE_LIMITER_UNAVAILABLE |
Every response that reached the limiter carries extensions.cost:
{
"extensions": {
"cost": {
"requestedQueryCost": 12,
"throttleStatus": { "maximumAvailable": 1000, "currentlyAvailable": 988, "restoreRate": 50 }
}
}
}
A scalar costs 0, an object 1, and a list counts its page size (or 100 rows at the root and 10 rows nested when it has none). The storefront bucket is separate from your app API bucket.
Versions
Storefront versions follow the app API's calendar: a version is supported for at least 12 months after it
ships. A deprecated version still answers, with Deprecation and Sunset headers. A sunset version answers
410. A field is never removed from a supported version; a breaking change ships in a new version.
Errors
| Status | extensions.code | Meaning |
|---|---|---|
| 401 | UNAUTHENTICATED | The storefront token or the buyer token is missing, expired or revoked |
| 403 | FORBIDDEN | The operation, field or input is not in this version, or needs a scope you do not hold |
| 400 | MAX_COST_EXCEEDED | The operation costs more than 1000 points |
| 429 | THROTTLED | Your storefront bucket is full; retry after retryAfterSeconds |
| 503 | RATE_LIMITER_UNAVAILABLE / SERVICE_UNAVAILABLE | ARC could not check the request; retry shortly |