Build your own storefront using Shoporama's headless API
Guide for Developers: Build your own storefront using the shoppens/api, featuring products, pages from the Page Designer, a shopping cart, discount codes, checkout, payment, and customer accounts. The full reference is available at shoporama.dk/headless.
Every Shoporama store has a headless API that you can use to build your own storefront: products, pages from the Page Designer, shopping cart, discount codes, checkout, payment, and customer account. The API uses the store’s own engine, so the store’s rules for pricing, inventory, shipping, and payment apply throughout the entire process. Here, we’ll walk you through the flow from the first page view to the receipt, as well as the pitfalls you need to be aware of. All endpoints and fields are listed in the reference for the headless API.
Headless API or REST API?
Shoporama has two APIs, and each serves a different purpose.
- The Headless API is hosted at the store’s own address, https://din-shop.dk/api. It requires no API key and operates using the customer’s own permissions, just as when the customer shops in the store. The cart, checkout, and login follow the customer’s cookies. This is the API for the storefront.
- The REST API uses an API key from Integrations → API Access or OAuth and can create and edit products, orders, customers, and much more. It’s intended for integrations and your own systems, and the key must never be stored in a browser. Read more in REST API and OAuth.
If you send a REST key to the headless API, it will be ignored. “Headless” means that the store’s front end is separated from the back end that manages products, the shopping cart, and orders. See “headless commerce” and “REST API” in the glossary.
Before you begin: domain, cookies, and countries
The shopping cart, checkout, and login only work when your storefront is running on the store’s primary domain (or a country-specific domain) and calls the API from the same domain. The primary domain is listed under Web Store → Domain.
- The cart, shipping address, and customer login are stored in cookies (session, basket, and customer_token), just like in the store. Therefore, call the API from the browser with credentials: 'include'. See Which cookies does Shoporama set?
- The API only allows calls with cookies from the store’s own domains. For all other domains, the API responds with ` Access-Control-Allow-Credentials: false`, so the browser rejects calls with cookies.
- The catalog and content (products, categories, search, pages, and menus) can be retrieved from any domain. Make these calls without the 'include' credentials.
- There is no token-based access to the shopping cart. The API identifies the shopping cart based on the customer’s cookies, not on a header or a token in the URL. The “token” field in the shopping cart response cannot be used to retrieve the shopping cart in another request.
- The domains listed under “Multiple Domains” forward all requests to the primary domain, including calls to /api. Therefore, run the storefront on the primary domain and not, for example, on the www version if it is not the primary domain.
- After payment, the customer is always redirected to the store’s own /thanks page at the store’s (or the country’s) address. This cannot be changed on a per-call basis.
The simplest approach is therefore to build the storefront as a theme, just like Washington. A theme consists of Smarty templates; see Themes: Create and Edit Your Own Theme Templates. In addition to the app itself, the theme must include templates for the payment page (payment.html), the receipt (thanks.html), the customer pages (user-...html), and the cancellation page (withdrawal.html). This way, all pages will run on the store’s domain, including the payment page, the receipt, the customer pages, and the cancellation page. If the store is to have its own domain, see Move your store to your own domain.
If the store sells in multiple countries, use the country-specific address, e.g., https://din-shop.dk/en/api or /api on the country’s own domain. This ensures that the language, currency, prices, shipping countries, and payment methods match the country. Use the same URL throughout the session; otherwise, the language and currency will change along the way. For `resolve`, send the path without the country prefix, e.g., ` /en/api/resolve/coffee ` and not ` /en/api/resolve/en/coffee`. The addresses in the menus include the prefix. See “Sell in Multiple Countries and Languages ” and “Prepare Your Theme for Multiple Languages and Countries.”
Response Format and Error Codes
All responses have the same format. A successful call contains its data in `data`. An error includes the HTTP status code, a message, and a reason in ` error.details.reason`:
{ "success": true, "data": { ... } }
{
"success": false,
"error": {
"code": 400,
"message": "Payment method not selected",
"details": { "reason": "payment_missing" }
}
}
All errors have a `reason`. Use this for your own messages to the customer, rather than the `message`, which may be in Danish or English. The ones you’ll typically need to handle are:
- Cart: requires_variant, sold_out, stock (with "available" and the quantity in stock), product_not_found, product_unavailable, not_added, customer_input (with " attribute_errors " for fields the customer fills out themselves), amount_missing, and amount_invalid.
- Discount codes: voucher_missing, voucher_not_found, and voucher_expired. The cart is located in details.basket.
- Points: points_invalid (with available, the number of points the cart can hold) and loyalty_inactive. If the user is not logged in, points return login_required.
- Shipping and payment: shipping_invalid, payment_missing, and payment_not_available.
- Complete the order: empty_basket, stock, basket_unavailable, min_order_value (with the amount excluding tax), delivery_country, address_missing, blocked, order_in_progress, order_failed, and payment_link_failed.
- Customer account and orders: login_missing, login_failed, login_required, order_not_found, and order_verification.
- General: Errors without a more specific cause can be identified by the status code, e.g., not_found, bad_request, method_not_allowed, request_too_large, rate_limited, and internal_error. A server error never displays an internal error message.
The API has a limit of 300 calls per minute per session. After that, it responds with HTTP 429 and “rate_limited” until the next minute begins. The store also has a limit per IP address for all requests, including those without cookies. If this limit is exceeded, the IP address is blocked for one hour with an HTTP 429 response as plain text, not JSON.
The API does not include a version in the URL. New fields may be added, so write your code to ignore fields it does not recognize.
Retrieve products, categories, and search results
- GET /api/products returns the products that are online and whose brands the customer is authorized to view. Use the limit (maximum 250, otherwise the store’s page size), offset, and category_id. The response includes pagination with the total number of pages and the current page number. ?gtin= looks up a barcode.
- GET /api/products/{id} returns a single product. You can also use the product’s rewrite_url instead of the ID.
- GET /api/frontpage returns the products in the store’s front page category.
- GET /api/categories returns the store’s online categories, and GET /api/categories/{id} returns a single category.
- GET /api/search?q= uses the same search as the store and returns a maximum of 250 products.
A product includes, among other things, price (including tax), old_price, buyable, images in five formats (original, large, medium, small, and thumbnail), attributes, variants, and meta with title and description. If the product requires a deposit, it is listed under “deposit” with the name and amount per item, including tax; otherwise, the field is null. Names and texts are in the language of the country.
The `variants ` field contains only the variants that are available for purchase. Each variant has `attributes` in the format attribute-id → value-id, and you send this object when the item is added to the cart. A variant’s price is null when the variant does not have its own price. Fields that the customer must fill in themselves (e.g., text for engraving) are listed in attributes with input_mode: "customer".
Pages and URLs with `resolve`
Using GET /api/resolve/{path}, your storefront can use the shop’s own URLs. You send the path the customer has opened and receive the corresponding URL. The homepage is GET /api/resolve/. The `type` field can be one of the following:
- home: the front page. You retrieve the products using /api/frontpage.
- category and landing: the category or landing page with products and pagination, which is null when there is only one page. The first page is ?p=0, the next is ?p=1, and the page size is the store’s default (on landing pages, it’s the landing page’s own).
- product, page, and blog: the product, the static page, or the blog post. blog_list is the blog overview at /blog.
- redirect: one of the store’s redirects from Webshop → Redirects. Redirect the customer to data.url so that old links continue to work.
- not_found: the page does not exist. The response returns an HTTP 200 status code, so your storefront must display a 404 page itself and return a 404 status code if it renders on the server.
The response also includes blocks from the Page Builder for the page as finished HTML, divided into top and bottom sections. The HTML is rendered using the shop’s active theme’s templates and uses its CSS classes. The field is null when the page has no blocks or the theme does not include the Page Builder, and on category and landing pages, the blocks appear only on the first page. If you already have the blocks from the server, add ?blocks=0. Read about the blocks in the article “Page Builder.”
You build the navigation based on GET /api/menus, so the store owner can manage it themselves under Content → Menus. Each menu has a location: top, bottom, left, or right, which corresponds to Top, Bottom, Left, and Right in the “Menu Location” field. Only menus with a specified placement are included; if no menu has the “Top” placement, the store’s first menu will appear at the top. An item with the type “menu” is a heading for a submenu without a URL, and “external” indicates whether the link goes to another domain. See Menus and Navigation.
Shopping Cart and Discount Codes
- POST /api/basket with product_id, amount (1–100), and attributes adds an item to the cart and returns the entire cart. The first call creates the cart and sets the cookie.
- PUT /api/basket/{line_id} with amount (0 or greater) updates the quantity, and 0 removes the line. DELETE /api/basket/{line_id} removes it.
- POST /api/voucher/apply with code applies a discount code or gift card, and POST /api/voucher/remove removes the code. The response includes the discount and the updated cart in the `basket` parameter.
Always use the line_id from the cart when updating or removing a line, e.g., 232156:4301+33385 for a variant. Treat it as text that you do not construct yourself, and pass it through `encodeURIComponent()`. A plain product ID targets the first line containing the item, and that might be the wrong variant. A line that doesn’t exist returns the cart unchanged and generates no error.
Display the amounts exactly as the API returns them, and do not add the lines together yourself. The amounts are the store’s own calculations, including promotions, discount codes, volume discounts, shipping, and the country’s currency; the subtotal is the total of the items after discounts have been applied. If the cart includes a deposit, the `deposits` and `deposit_total` fields are included. The deposit is included in the total and VAT but not in the subtotal, so display a line item for the deposit; otherwise, the numbers won’t add up. Before the shipping step, ` shipping` is the store’s standard shipping rate, and `shipping_chosen` is `false`. If the cart includes a promotion or a discount code, `campaign` and `voucher` are included, and the discounts from these have already been deducted from the subtotal.
A discount code that doesn’t exist or can’t be used will also remove any code that was already applied to the cart. A valid code can result in a discount of 0, for example, when it only applies to orders over a certain amount. It remains active and applies the discount when the cart meets the condition. See Create a Discount Code.
Pay with points. If the store has a loyalty program, the cart includes a “points” field: “balance” is the customer’s balance, “available” is the number of points the cart can hold according to the store’s rules; “used” and “discount” are the points used and their value; “earns” are the points the order earns; and “rate” is, for example, 10 points = 1 DKK. For a guest, “balance” and “available” are null. POST /api/points/apply with points applies points to the order and requires a login, and POST /api/points/remove removes them again. Too many points results in a 400 points_invalid error and also removes any points that were in the cart. A discount code can only be used in conjunction with points if the code allows it; otherwise, ` available ` is 0.
The point discount is deducted from the total and VAT, so display it as a separate line. Before the order is created, the points are capped again to the maximum the cart can hold, and the order deducts them from the customer’s balance. Points are earned on the order, just as in the store’s own themes, when the order is approved or shipped. On the order, the points used are listed in `points.used`, the discount in `totals.point_discount`, and `points.earned ` represents the points the order has generated so far.
Checkout Step by Step
GET /api/checkout returns the cart, shipping methods, payment methods, and countries in a single call, along with the addresses the customer has already filled out. Only the methods the customer can actually select are included. The checkout itself is POST /api/checkout with one step:
- address: email, phone, name, address, zipcode, city, country_id, and, if applicable, company fields, comments, newsletter subscription, and an alternate shipping address using the delivery_ fields. The response includes missing_fields and delivers_to_country, so you can display errors immediately. The order is not rejected until it is completed. A ` country_id` that is not one of the store’s countries does not result in an error; instead, the store’s default country is used.
- shipping: shipping_id and, for package shop methods, delivery_shop_id (the package shop’s number from delivery_shops). A method not on the list is rejected. If you do not specify a pickup location, the store selects the first one on the list, just as the store’s own checkout does.
- payment: payment_gateway_id. Only methods that are online and permitted in the customer’s country.
- complete: completes the order; see the next section. You can also call POST /api/checkout/complete.
This is where most errors occur:
- Always include the step, and send the full address every time. The address step replaces all fields with what you send, including those you omit, and a call without `step` is treated as the address step. If the customer is logged in, checkout always uses the account’s address, and the address step only saves a new address to the account when `missing_fields` is empty.
- Reload the checkout page after entering the address. Package pickup locations are retrieved based on the shipping address and ZIP code, and payment methods are filtered by billing country. Before the address step, `delivery_shops` is empty for a guest, and for a logged-in customer, the list matches the account’s address. If the list is still empty afterward because the shipping carrier’s lookup fails, the method can be selected without a pickup location.
- Do not skip the shipping step. Without it, the store’s default shipping method and the first available pickup location will be used—not the ones the customer would have chosen. If the customer changes the delivery country during the address step, the selected shipping method and pickup location are reset, so the customer must select them again.
- The types are not the same. ` shipping_methods[].id ` and the cart’s `product_id` are strings, while `payment_methods[].id ` and the item’s ID are numbers. The cart’s `shipping_id`, on the other hand, is a number, so compare it using `String()`.
- Terms and consent are your responsibility. The API does not verify that the customer has accepted the terms of sale. Retrieve them using GET /api/settings (terms), and display them in your checkout. Items with ` withdrawal_exempt_reason: "digital_content"` require the customer’s explicit consent, which your checkout must also obtain.
Payment and Receipt
Send step: "complete" with payment_flow: "storefront". The payment method is the one from the payment step, but you can also include it as payment_id. Before the order is created, the API checks the same things as the store’s own checkout: inventory, minimum order amount, delivery country, address, shipping, and payment method. There are two types of responses:
- Payment: `payment_redirect: true ` and `payment_url`. With the storefront flow, the store’s own payment page (`/payment`) redirects to the payment window. This works with all of the store’s payment methods, and a free order follows the same path. The payment page uses the theme’s payment.html template, so if you’re building your own theme, this file must be present.
- The order has been created: for payment methods that do not involve online payment, such as bank transfer and invoice. The response includes a redirect to the receipt, ` webshop_order_id ` (the order number the customer sees), `chk`, and `payment_info ` with the store’s text for the method, such as the account number.
Both addresses point to the store’s domain, so redirect the customer using ` payment_url ` or a redirect. Without `payment_flow`, the API automatically provides the payment window address for QuickPay, OnPay, Pensopay, Flatpay, Stripe, Mollie, and Nets Easy, while other methods still go through `/payment`. If the payment window cannot be launched in that flow, the API responds with a 502 error and `payment_link_failed`, and no order is created. See “How do you accept online payments? ” and “payment window” in the glossary.
If the store has disabled “Keep the cart open until the order is paid?” under Webstore → General, the storefront flow creates the order before payment, just like the store’s own checkout. In this case, `payment_url` is the order’s payment page, and the order number is included in the response. See “What’s the difference between an open cart and a closed cart?”
Double-clicking does not create two orders. If the cart has already been converted into an order, a new “complete” request returns the same response as the first time. If a request is made while the order is being created, it waits up to approximately 6 seconds and then receives the same response. Only if the order is not ready by then will the API respond with a 409 error and “order_in_progress.” Still, lock the button while the request is in progress. For card payments with an open cart, the order is not created until after payment is complete, so a new “complete” request will simply return a new link.
The receipt. After payment, the customer is redirected to the store’s /thanks page with the basket_id or order_id and “chk” in the URL. The page automatically finds the order and passes it to the theme’s `thanks.html` template, which Washington uses. If you want to retrieve the order in your app, use ` GET /api/order ` with the same parameters. For demo payments and PayPal, the customer’s email is in the `email` parameter instead of `chk`, so use that template instead. `chk` is an MD5 hash of the order’s email address, ensuring the order is only displayed with the correct email. The response includes the prices the customer paid, and `order_id` is the order number. If the order includes a deposit, the amount is listed in `totals.deposit`. Unlike the shopping cart, the deposit is already included in the subtotal here, and the deposit lines do not appear in `products`. If the customer reaches the receipt page before the payment provider has confirmed the payment, `paid` may show as `false` for a moment. This does not mean that the payment failed.
The entire purchase in one example
The example runs on the store’s own domain. It adds an item to the cart, fills in the address, selects shipping and payment, completes the transaction using the storefront flow, and redirects the customer. In a real storefront, the customer selects the variant, shipping, pickup location, and payment method themselves.
// Runs on the store’s own domain. In a country with its own path: '/en/api'
const API = '/api';
async function api(path, body) {
const res = await fetch(API + path, {
method: body ? 'POST' : 'GET',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch (e) {}
if (json && json.success) return json.data;
const error = new Error(json ? json.error.message : 'HTTP ' + res.status);
error.status = res.status;
error.reason = json ? json.error.details?.reason : 'no_json';
error.details = json ? json.error.details : null;
throw error;
}
async function buy(productId) {
// 1. The product and a variant that can be purchased
const product = await api('/products/' + productId);
const variant = product.variants[0];
await api('/basket', { product_id: product.id, amount: 1, attributes: variant ? variant.attributes : {} });
// 2. The address. The country ID is specific to the store, so look it up
const { countries } = await api('/countries');
const denmark = countries.find(function (c) { return c.code === 'DK'; });
const address = await api('/checkout', {
step: 'address', email: 'kunde@example.com', phone: '12345678', name: 'Karen Holm',
address: 'Vestergade 12', zipcode: '8000', city: 'Aarhus C', country_id: denmark.id, newsletter: 0
});
if (address.missing_fields.length || !address.delivers_to_country) return address;
// 3. Retrieve shipping and payment options again AFTER the address (package pickup locations for the ZIP code)
const checkout = await api('/checkout');
const shipping = checkout.shipping_methods[0];
const shippingStep = { step: 'shipping', shipping_id: shipping.id };
if (shipping.delivery_shops.length) shippingStep.delivery_shop_id = shipping.delivery_shops[0].number;
await api('/checkout', shippingStep);
// 4. Payment method
await api('/checkout', { step: 'payment', payment_gateway_id: checkout.payment_methods[0].id });
// 5. Complete. Lock the button while the call is running
const result = await api('/checkout', { step: 'complete', payment_flow: 'storefront' });
// Payment: the store's /payment page. Bank transfer and invoice: the receipt
window.location.href = result.payment_url || result.redirect;
}
If something goes wrong, api() throws an error with a reason, so you can redirect the customer back to the correct step: shipping_invalid for shipping, 'address_missing' and 'delivery_country' for the address, 'payment_missing' and 'payment_not_available' for payment, and 'stock', 'basket_unavailable', or 'min_order_value' for the cart. For 'order_in_progress', wait a moment and call 'complete' again.
Customer Account and Login
- POST /api/auth with an email and password logs the customer in and sets the same cookie as the store’s own login. The customer is thus logged in to both your storefront and the store. An incorrect login returns a 401 and “login_failed.”
- POST /api/auth/logout logs the customer out.
- GET /api/customer returns the logged-in customer’s name, address, customer group, points balance (points), and links to the customer pages, or returns customer: null.
- GET /api/orders returns the customer’s orders, and GET /api/orders/{order number} returns a single order with the same fields as /api/order. Without a login, these requests return a 401 error and “login_required.”
When logged in, the customer sees their customer group prices, their address is pre-filled, and they have access to payment methods available only to logged-in customers. These are methods with the option “Automatically mark new orders as paid?”, such as invoicing for business customers. The address step saves a complete address to the customer’s account, and a logged-in customer always shops using the account’s email address, even if you enter a different one in the address step. See Customer Groups and B2B Prices.
"Create Account," "Forgot Password," and "Edit Information" are not available in the API. Instead, link to the store’s own pages: /user-sign-up, /user-reset-password, and /user-edit. This ensures the customer receives a spam check, a welcome email, sign-up points, and VAT number verification, just as in the rest of the store. If you’re building a theme, these pages must be available as templates. Washington has them.
Washington as an example
Washington is one of Shoporama’s themes, and it’s built entirely on the headless API. The entire store is a single JavaScript program without any frameworks, and the theme is a good starting point if you want to build your own storefront as a theme. You can download it from the Washington theme page, and the setup instructions for store owners are included in the Washington theme. Upload the theme files under Design → SFTP Access or edit them under Design → Code Editor. See Log In with SFTP and the Code Editor.
Washington also shows what you need to provide yourself for a custom storefront:
- SEO: Washington renders the initial page view on the server using the Page Builder’s blocks, title, meta description, canonical tag, Open Graph, and product data (JSON-LD). Product lists and product pages are built in the browser. See Implement Structured Data in Your Shoporama Theme and JSON-LD in the Glossary.
- Tracking: Washington sends GA4 events (e.g., view_item, add_to_cart, begin_checkout, and purchase) to dataLayer, and on every page transition, it sends virtual_page_view with page_location, page_path, and page_title. If your storefront wants to track page views and purchases, it must send them itself. See “Implement Tracking in a Shoporama Theme” and “dataLayer” in the glossary.
- Scripts from the store: In Washington, apps and custom scripts from the store’s HTML fields run only on the first page load, because the page is not reloaded when switching pages. The same applies to your own app if it switches pages in the same way.
- The store’s own pages: Washington provides templates for the checkout page, the receipt, the customer pages, and the cancellation page (/withdrawal), so they run on the store’s domain with the same design.
Frequently Asked Questions
Do I need an API key to get started?
No. Open https://din-shop.dk/api/products?limit=5 in your browser, and you’ll see the store’s products. The API can only do what a customer in the store can do: view online products and pages, and manage their own cart, account, and orders. It cannot modify products, prices, or other customers’ orders, and methods that mark an order as paid are only available to logged-in customers. For everything else, use the REST API with a key.
Can the storefront be hosted on a different domain than the store, for example, on Vercel?
Only for the catalog and content. The shopping cart, checkout, and login require the customer’s cookies, and the browser only sends those when the page is displayed from the store’s primary domain (Webshop → Domain) and calls the API on the same domain. If you route the calls through your own server, the session is stored on your server and not on the customer’s, so the store’s payment page won’t be able to find the cart. The payment and receipt on /thanks always take place on the store’s domain. The secure solution is to build the storefront as a theme.
Does the 300-calls-per-minute limit apply when my server renders the pages?
There are two limits. The API’s own limit of 300 calls per minute is counted per sessio
Related articles
REST API
Complete guide to Shoporama's REST API: authentication, all endpoints, examples and Swagger documentation.
OAuth - give apps access to your webshop
Learn how OAuth works in Shoporama. Give third-party apps secure access to your online store without sharing your password.
Page designer
Lær hvordan du bruger Sidedesigner i Shoporama til at opbygge sidelayouts med blokke som hero-sektioner, produktlister, bannere og meget mere, helt...
Webhooks in Shoporama
Complete guide to webhooks in Shoporama. Get automatically notified on a URL when something happens in your online store. List of all events,...
Move your shop to your own domain
Guide to moving your Shoporama online store to your own domain with DNS setup, SSL and domain validation.
Prepare your theme for multiple languages and countries
Step-by-Step Guide for Developers: How to Make Your Own Shoporama Theme Work with Countries, Including Translated Texts, the Country's Currency and...
Related features
Headless Commerce and OAuth
Use Shoporama as a headless backend with OAuth login and a comprehensive REST API. Build custom frontends, apps and integrations.
REST API - build exactly the integration you want
Full REST API with access to products, orders, customers and more. Build your own integrations, apps or a headless frontend.
Headless commerce
Headless commerce separates frontend and backend via APIs. Learn what it is, the pros and cons, and when it makes sense for your webshop.
REST API
REST API is a standard for system communication over HTTP. Read about REST APIs, integrations and how webshops use them.
Webhook
A webhook automatically sends messages between systems when events occur. Read about webhooks, integrations and real-time communication in eCommerce.