Headdy for Craft CMS

API reference

Headdy Storefront API — v1

Everything below is mounted under <base path>/v1, which defaults to:

https://example.com/api/storefront/v1

The shape of every response

Success:

{ "success": true, "cart": { ... } }

Failure:

{
  "success": false,
  "error": {
    "code": "purchasable_unavailable",
    "message": "“Blue T-shirt, Large” is not available.",
    "errors": { "qty": ["Not enough stock."] }
  },
  "cart": { ... }
}

Branch on error.code, never on error.message. Codes are contract and will not change inside v1. Messages are translated and may be reworded. error.errors only appears when there are field-level validation messages and Verbose errors is on.

A failed cart mutation still returns the cart as it stands, so a client can re-render without a second request.

Money

Every amount is an object, never a bare number:

{ "amount": "24.99", "minorUnits": 2499, "currency": "USD", "formatted": "$24.99" }
  • amount — a decimal string. JSON numbers are IEEE 754 doubles and 24.99 is not one of them.
  • minorUnits — the integer a payment processor wants.
  • formatted — for display. Localised. Never parse it.

Authentication

HeaderWhat it is
X-Headdy-KeyThe API key's public half. Safe in browser JavaScript.
X-Headdy-SecretThe secret half. Server-side callers only, and only in secret auth mode.
X-Headdy-CartA cart token. Also accepted as Authorization: Bearer hdc_….
X-Headdy-CustomerA customer access token. Also accepted as Authorization: Bearer hda_….
X-Headdy-StoreA store ID, for a multi-store build.
X-Headdy-SiteA site ID or handle.

There is no CSRF token and no cookie. A CSRF token defends a credential the browser attaches on its own; a bearer token is only ever sent deliberately, which is the same defence.

Carts

POST /carts

Creates a cart. Accepts the same body as an update, so the common "add to cart with no cart yet" path is one request rather than two.

curl -X POST "$API/carts" \
  -H 'Content-Type: application/json' \
  -H "X-Headdy-Key: $KEY" \
  -d '{"items":[{"purchasableId":123,"qty":2,"options":{"engraving":"Ada"}}]}'

201. The response's cart.token is the credential for every later call — hold it in memory (and in a cookie or localStorage if the basket should survive a reload).

GET /carts/current

PATCH /carts/current

Applies any combination of changes in one request. Every key is optional. A key present with a null value clears that thing; an absent key leaves it alone — which is exactly why the API takes JSON and not a form body.

{
  "addItems":    [{ "purchasableId": 123, "qty": 1, "options": {}, "note": "" }],
  "updateItems": { "456": { "qty": 3 } },
  "removeItems": [789],
  "clearLineItems": false,
  "shippingAddress": { "addressLine1": "1 High St", "locality": "Charlotte", "administrativeArea": "NC", "postalCode": "28202", "countryCode": "US" },
  "billingSameAsShipping": true,
  "email": "ada@example.com",
  "couponCode": "SPRING10",
  "shippingMethodHandle": "standard",
  "gatewayId": 1,
  "message": "Leave it with the neighbour",
  "fields": { "giftWrap": true }
}

Changes are applied in a fixed order regardless of the order you wrote them: clear → items → addresses → email → coupon → shipping → gateway → fields. So one big patch and six small ones give the same result.

Line item verbs

MethodPath
POST/carts/current/items
PATCH/carts/current/items/{lineItemId}
DELETE/carts/current/items/{lineItemId}
DELETE/carts/current/items (empties the cart, keeps the token)

{lineItemId} accepts a line item's id or its uid.

Other cart verbs

MethodPathBody
PUT/carts/current/addressesshippingAddress, billingAddress, billingSameAsShipping, shippingSameAsBilling
PUT/carts/current/emailemail
PUT/carts/current/couponcouponCode (null removes it)
PUT/carts/current/shipping-methodshippingMethodHandle
GET/carts/current/shipping-methods—
POST/carts/current/attachclaims a guest cart for the signed-in customer
DELETE/carts/currentthrows the cart away

Address fields are the CLDR names Craft itself stores — addressLine1, locality, administrativeArea, postalCode, countryCode and the rest — so an address you read can be written straight back.

Checkout

GET /checkout

The whole picture in one request, so a checkout UI never has to discover the store's rules by trial and error:

{
  "checkout": {
    "ready": false,
    "missing": ["shippingAddress", "shippingMethod"],
    "requiresPayment": true,
    "amountDue": { "amount": "49.98", "minorUnits": 4998, "currency": "USD", "formatted": "$49.98" },
    "availableShippingMethods": [ ... ],
    "availableGateways": [ { "id": 1, "handle": "stripe", "paymentFormParamName": "paymentForm[stripe]", ... } ],
    "store": { "requiresShippingAddress": true, "allowsPartialPayment": false, ... }
  }
}

missing values: items, email, shippingAddress, billingAddress, shippingMethod, paymentMethod.

paymentFormParamName matters: Commerce derives it from the gateway handle and a client has no other way to know it, so it ships in the payload.

POST /checkout/pay

{
  "gatewayId": 1,
  "paymentForm": { "stripeToken": "tok_visa" },
  "returnUrl": "https://shop.example.com/thanks",
  "cancelUrl": "https://shop.example.com/cart"
}

Craft's own checkout hashes return URLs into a Twig form, which a JSON client cannot reproduce. Headdy validates them against Allowed redirect origins in the plugin settings instead. That list is empty by default, so off-site gateways cannot be driven from the API until you say where returns may land. Relative paths are always accepted. * allows any http or https origin; a custom app scheme has to be listed by name.

On success:

{
  "success": true,
  "order": { "isCompleted": true, "isPaid": true, ... },
  "transaction": { "hash": "…", "reference": "…", "status": "success", ... },
  "amountPaid": { ... },
  "outstandingBalance": { ... },
  "redirect": null
}

For an off-site gateway, redirect is populated instead of null:

{ "redirect": { "url": "https://gateway.example/pay/…", "method": "POST", "data": { "sig": "…" } } }

Honour method. GET means navigate. POST means submit data as a form — gateways that sign a POST body will reject a customer who arrives by navigation.

When the customer comes back, hand the gateway's commerceTransactionHash to:

POST /checkout/complete-payment

Takes no cart token: by then the cart is an order and its token has been revoked.

POST /checkout/complete

Completes an order that owes nothing — a fully discounted cart, or a store configured to allow checkout without payment. Anything with a balance must go through /checkout/pay.

Catalog

MethodPath
GET/products?search=&type=&ids=&slug=&orderBy=&page=&pageSize=&availableOnly=
GET/products/{idOrSlug}
GET/variants/{idOrSku}
GET/product-types

orderBy accepts title, -title, postDate, -postDate, id, -id. Anything else falls back to -postDate — the value reaches SQL, so it is a fixed list rather than a passthrough.

Customers — Pro

MethodPath
POST/customers/sessions — sign in, returns token + refreshToken
POST/customers/sessions/refresh — rotate the pair
DELETE/customers/sessions — sign out
POST/customers — register (off by default)
GET/customers/me
GET/customers/me/orders, /customers/me/orders/{number}
GET/POST/customers/me/addresses
PATCH/DELETE/customers/me/addresses/{id}
GET/customers/me/payment-sources
GET/orders/lookup?number=&email= — a guest checking a purchase

Signing in never logs the user into Craft. A customer token identifies a shopper to Headdy's own endpoints and nothing else; it cannot be replayed against the control panel. Control panel accounts are refused outright.

If you send a cart token alongside a sign-in, the cart is attached to the customer and returned in the response — so a shopper who logs in mid-checkout keeps their basket.

Sign-in uses Craft's lockout. Wrong passwords count towards maxInvalidLogins, exactly as a control panel sign-in does, so a locked account stays locked until Craft's cooldown passes. A control panel account is refused without its password being checked at all — it gets the same 401 customer_login_failed as a wrong guess — so the storefront API can neither lock an admin out nor be used to guess an admin's password. A password change signs out every customer token issued before it.

cart.customer names the account only to that account. On a request without the matching customer token it is null, even when the cart's email belongs to a registered customer — a cart token proves you hold a cart, not who you are.

Credential endpoints are rate limited per address, whatever the API key's own limit: sign-in 10 a minute (and 5 per account name), registration 5, refresh 30, order lookup 20. Over the limit is a 429 rate_limited with Retry-After.

Registration follows Craft's "Verify email addresses" user setting. With it on (Craft's default), POST /customers creates a pending account, sends Craft's activation email, and answers 202 with {"verificationRequired": true} and no tokens — sign in once the address is confirmed. With it off, the account is active at once and the response is 201 with verificationRequired: false plus the token pair. password is optional only when verification is on (the activation email then lets the customer set one). fields may only set the custom fields listed in the Fields registration may set setting; anything else is ignored.

Store

MethodPath
GET/ — discovery: version, edition, which features are on
GET/store — currency, countries, gateways, sites, checkout rules
GET/stores — every store, for multi-store

Error codes

CodeStatusMeans
api_disabled503The API is switched off in settings.
commerce_unavailable503Commerce is missing or disabled.
unauthorized401No key, or a bad one.
forbidden403Key lacks a scope, or the origin is not allowed.
pro_required403Lite install, Pro endpoint.
rate_limited429Over the per-minute limit. Retry-After is set.
invalid_json400The body did not parse.
invalid_request422Malformed parameters.
cart_not_found404No cart token, or an unknown one.
cart_token_expired401The token existed but has expired — start a new cart.
cart_completed409The cart is already an order.
cart_locked409Another request holds this cart. Retry.
cart_invalid422The cart failed validation. error.errors names the fields.
line_item_not_found404No such line item in this cart.
purchasable_not_found404No such purchasable.
purchasable_unavailable422Out of stock, disabled, or not for sale here.
shipping_method_unavailable422Not an option for this cart. availableShippingMethods lists what is.
checkout_incomplete422missing lists what to collect.
payment_gateway_unavailable422No usable gateway.
payment_amount_changed409The total moved between quote and payment. changed says which.
payment_failed402The gateway declined.
redirect_not_allowed422Return URL is not an allowed origin.
transaction_not_found404Unknown transaction hash.
customer_login_failed401Bad credentials, or login is off.
customer_token_expired401Access or refresh token is expired or revoked.
customer_exists409An account already exists for that email.
server_error500Logged. The message is only echoed back in dev mode.

GraphQL — Pro

Registered on Craft's own GraphQL endpoint. Authentication is a cartToken argument, not a cookie.

A schema has to be granted Headdy's carts. Under Settings → GraphQL → Schemas, tick Headdy storefront → Read carts and checkout state (headdyCarts:read) for the queries and Create, change and complete carts (headdyCarts:edit) for the mutations. A schema without them — the public schema included — has no headdyCart* fields at all, and a resolver reached anyway refuses. The plugin's per-minute rate limit applies per address.

mutation {
  headdyCartCreate(items: [{ purchasableId: 123, qty: 1 }]) {
    token
    totalQty
    totals { total { formatted } }
  }
}

mutation {
  headdyCartAddItem(cartToken: "hdc_…", purchasableId: 456, qty: 2) {
    lineItems { id description qty total { formatted } }
  }
}

query {
  headdyCheckout(cartToken: "hdc_…") { ready missing amountDue { formatted } }
}

Full list: headdyCartCreate, headdyCartAddItem, headdyCartUpdateItem, headdyCartRemoveItem, headdyCartClear, headdyCartSetEmail, headdyCartSetAddresses, headdyCartApplyCoupon, headdyCartSetShippingMethod, headdyCartSetGateway, headdyCheckoutComplete; queries headdyCart and headdyCheckout.

Payment stays on REST. A gateway redirect needs a real HTTP response to hand back, and squeezing one through a GraphQL mutation makes for a worse client than a single POST.

Line item options are a JSON string in GraphQL — GraphQL has no map type, and a plugin cannot invent one input type per store's option keys.

Webhooks — Pro

Deliveries are queued, so a slow receiver never holds a checkout open. Each carries:

X-Headdy-Topic: order.paid
X-Headdy-Delivery: 5d1c…  (a UUID, the same on every retry of one delivery)
X-Headdy-Signature: t=1717171717,v1=<hmac-sha256 of "<t>.<body>">

Verify the HMAC with the endpoint's signing secret, and reject anything more than a few minutes old — the timestamp is inside the signed material precisely so replay is detectable. De-duplicate on X-Headdy-Delivery: a retried delivery carries the same ID.

Outside dev mode, an endpoint must resolve to a public address — private, loopback, link-local and reserved ranges are refused — and redirects from it are not followed.

Topics: cart.created, cart.updated, cart.completed, order.paid, order.statusChanged, payment.failed.