Building a storefront
This page walks through one shopper's journey from a browser. Every call works the same from a server. The API reference has the full contract.
A tiny client
const API = 'https://shop-admin.example.com/api/storefront/v1';
const KEY = 'hd_pk_…';
async function headdy(path, { method = 'GET', body, cart, customer } = {}) {
const res = await fetch(API + path, {
method,
headers: {
'Content-Type': 'application/json',
'X-Headdy-Key': KEY,
...(cart && { 'X-Headdy-Cart': cart }),
...(customer && { 'X-Headdy-Customer': customer }),
},
body: body && JSON.stringify(body),
});
const json = await res.json();
if (!json.success) throw Object.assign(new Error(json.error.message), json.error, { cart: json.cart });
return json;
}
Notice what isn't there: no credentials: 'include', no CSRF token request, no form encoding.
Add to cart
The first add creates the cart:
let { cart } = await headdy('/carts', {
method: 'POST',
body: { items: [{ purchasableId: 123, qty: 1 }] },
});
localStorage.setItem('cart', cart.token);
Every later add goes to the same cart:
({ cart } = await headdy('/carts/current/items', {
method: 'POST',
cart: localStorage.getItem('cart'),
body: { purchasableId: 456, qty: 2, options: { engraving: 'Ada' } },
}));
If the token has expired you get cart_token_expired. Drop it and start a new cart.
Render it
Every amount comes as an object, so you never do arithmetic on floats:
cart.lineItems.map(li => `${li.qty} × ${li.description} — ${li.total.formatted}`);
cart.totals.total.formatted; // "$49.98"
cart.totals.total.minorUnits; // 4998
Checkout
Collect what checkout needs in one PATCH. Headdy applies the changes in a fixed order (items,
then addresses, then email, coupon and shipping), so it doesn't matter what order you write them in:
await headdy('/carts/current', {
method: 'PATCH',
cart: token,
body: {
email: 'ada@example.com',
shippingAddress: { addressLine1: '1 High St', locality: 'Charlotte', administrativeArea: 'NC', postalCode: '28202', countryCode: 'US' },
billingSameAsShipping: true,
couponCode: 'SPRING10',
},
});
Then ask what's still missing, rather than guessing the store's rules:
const { checkout } = await headdy('/checkout', { cart: token });
checkout.ready; // false
checkout.missing; // ["shippingMethod"]
checkout.availableShippingMethods; // pick one, then PUT /carts/current/shipping-method
checkout.availableGateways; // each carries the paymentFormParamName Commerce expects
Pay
For a gateway that charges in place, such as Stripe with a payment method created by Stripe.js:
const result = await headdy('/checkout/pay', {
method: 'POST',
cart: token,
body: { gatewayId: 1, paymentForm: { paymentMethodId: pm.id } },
});
result.order.isPaid; // true
localStorage.removeItem('cart'); // the cart is an order now; its token is revoked
For an off-site gateway, add the origin you return to under Allowed redirect origins first, then:
const { redirect } = await headdy('/checkout/pay', {
method: 'POST',
cart: token,
body: { gatewayId: 2, returnUrl: 'https://shop.example.com/thanks', cancelUrl: 'https://shop.example.com/cart' },
});
if (redirect.method === 'GET') {
location.href = redirect.url;
} else {
// Gateways that sign a POST body reject a shopper who arrives by navigation.
const form = Object.assign(document.createElement('form'), { method: 'POST', action: redirect.url });
for (const [name, value] of Object.entries(redirect.data)) {
form.append(Object.assign(document.createElement('input'), { type: 'hidden', name, value }));
}
document.body.append(form);
form.submit();
}
When the shopper lands on your return page, pass the gateway's commerceTransactionHash to
POST /checkout/complete-payment.
An order that owes nothing, such as a fully discounted cart, completes with
POST /checkout/complete instead.
Customer accounts (Pro)
const session = await headdy('/customers/sessions', {
method: 'POST',
cart: token, // optional: attaches the guest cart to the account
body: { loginName: 'ada@example.com', password: '…' },
});
// session.token (short-lived), session.refreshToken (long-lived, single use)
const { orders } = await headdy('/customers/me/orders', { customer: session.token });
When the access token expires (customer_token_expired), swap the refresh token for a new pair at
POST /customers/sessions/refresh. A refresh token works once. Keep the new one it hands back.
Every failed sign-in returns the same customer_login_failed, whether the account doesn't exist, is
suspended or has the wrong password. Show one message for all of them.
GraphQL (Pro)
Once your schema has the Headdy storefront permissions (see Configuration):
mutation Add($token: String!) {
headdyCartAddItem(cartToken: $token, purchasableId: 456, qty: 2) {
totalQty
totals { total { formatted } }
}
}
The mutations go through the same service as REST, so a cart changed one way reads identically the other way. Payment stays on REST, because a gateway redirect needs a real HTTP response.