Developers
Build a shop's own website on Debian POS.
The storefront API gives a website or app the shop's products, prices and stock, its customers' accounts and its checkout, live from the same system its tills sell from. This guide is for the developer or agency the shop hires.
Before you start
The shop makes a key for you on its Storefront API page in Debian POS, one for each website or app, and can revoke it at any time. Custom frontends come with the Business plan and up; on a smaller plan every call answers 403.
A key is publishable: it says which shop a call is for and what it may reach, and is meant to sit in the website's own code, like a map key. It never lets anyone into the back office. Each key carries one or more scopes:
content- The shop's details, homepage, blog, FAQs and careers, and the contact and newsletter forms.
catalogue- Products, categories, brands and search, with prices and stock.
accounts- Customers signing up, signing in and managing their account.
checkout- The cart, coupons and placing and paying for orders.
The same page sets the branch whose prices and stock the website sells from, and the website's address, which password reset emails, Pesapal's return and every link in the sitemaps point to.
Making a call
Every call goes to https://app.debianpos.com/api/v1, sends the key in the X-Storefront-Key header and asks for JSON. Browsers may call it from any website: the API answers cross-origin requests, so a single-page app or a Next.js site can call it from the client or the server.
curl https://app.debianpos.com/api/v1/products?per_page=12 \
-H "X-Storefront-Key: dpk_your_key" \
-H "Accept: application/json"
const api = (path, options = {}) =>
fetch(`https://app.debianpos.com/api/v1${path}`, {
...options,
headers: {
'X-Storefront-Key': process.env.NEXT_PUBLIC_STOREFRONT_KEY,
'Accept': 'application/json',
'Content-Type': 'application/json',
...options.headers,
},
}).then((response) => response.json());
const { data: products } = await api('/products?per_page=12');
Answers and errors
A record comes back as data. A list that pages comes back in Laravel's paginator shape, with data, links and meta (current_page, last_page, per_page, total); ask for the next with ?page=2. Records are named by uuid, in both id and _id, and by slug where they have one. Money is in Kenya shillings, as a number. Times are ISO 8601 in UTC.
An error comes back with a status and a message meant for the customer, and for a form, errors keyed by field:
{
"message": "The phone field is required.",
"errors": {
"phone": ["The phone field is required."]
}
}
- 401 The key is unknown or revoked, or a call that needs a customer token has none or an expired one.
- 403 The key may not reach that scope, the shop's plan lacks custom frontends, or the shop is suspended.
- 404 No such record, or one the website does not show, such as a hidden product or another customer's order.
- 422 The request needs changing: show the message, and each field's.
- 429 Too many calls: wait the seconds in the Retry-After header.
Limits
Each key may make as many calls a minute as the shop set when making it: 120 unless it chose otherwise, and up to 600. Calls that send email or create records, such as the contact form, placing a guest order or a pre-order, have a lower limit of their own for each visitor, shown in the reference. Cache what changes rarely, such as the shop's settings, navigation and categories, for a few minutes on your server.
Customer sign-in
Customers register or sign in with their email and password and get a token, which the website keeps and sends with their calls in the Authorization: Bearer header, beside the key. A token lasts 90 days from its last use; signing out ends it. A customer who already shops in store and registers with the same phone and email gets their own account, with their purchases and points; a phone or email used by someone else is refused with a message saying so.
const { data } = await api('/auth/login', {
method: 'POST',
body: JSON.stringify({ email: 'wanjiru@example.com', password: 'helmet-2026' }),
});
const orders = await api('/orders', {
headers: { Authorization: `Bearer ${data.token}` },
});
Forgotten passwords: the website sends the email address to /auth/forgot-password; the customer gets an email linking to /reset-password?token=…&email=… on the shop's website address, and that page sends the token, email and new password to /auth/reset-password.
Cart and checkout
A guest's cart lives on the website: keep it in the browser, and ask the API for its totals and whether it is in stock. A signed-in customer's cart is kept by Debian POS, so it follows them between devices; when a guest signs in, sync sends the cart they built, which then takes the place of the one kept.
- Show the cart with
POST /cart/totals, which prices it at the website branch with any coupon, and check it withPOST /cart/stock-checkbefore the checkout page. - Place the order with
POST /orders/guest, orPOST /ordersfor a signed-in customer, who may also spend loyalty points. Send anidempotency_key, a value of your own for this checkout, so a double tap answers with the first order instead of a second. - The order waits for the shop to confirm it in the back office, where staff hear of it at once; the customer gets an email that it was received. Its stock is taken when it is confirmed, or as soon as it is paid in full online or its Lipa Pole Pole deposit is paid.
- Show its progress with
GET /orders/{uuid}/status, and its receipt as a PDF withGET /orders/{uuid}/receipt.
Paying online
With Pesapal turned on for the shop, an order placed with "payment_method": "PESAPAL" answers with a pesapal_redirect_url: send the customer there to pay by M-Pesa or card. Pesapal sends them back to /checkout/confirmation?order={uuid} on the shop's website, and that page asks GET /payments/status whether it went through, with the OrderTrackingId Pesapal adds to the address.
const params = new URLSearchParams(location.search);
const { data: payment } = await api(`/payments/status?OrderTrackingId=${params.get('OrderTrackingId')}`);
if (payment.payment_status === 'Completed') {
// Paid: show the thank-you, with payment.confirmation_code.
}
An order not yet paid can be paid later with POST /orders/{uuid}/pay, or /guest-pay for a guest. Where the shop offers Lipa Pole Pole, "payment_method": "LIPA_POLE_POLE" pays a deposit online first and the rest in instalments, each of the customer's own amount, sent as amount.
Sitemaps and RSS
Search engines and feed readers send no headers, so the feeds carry the key in their address, at https://app.debianpos.com/api/v1/feeds/{key}/: sitemap.xml, sitemap-images.xml, rss/products.xml, rss/new-products.xml, rss/blogs.xml. Every link in them is on the shop's website address, at the paths product/, category/, brands/ and blog/; serve those pages, or the feeds' own links will not resolve. Point Google Search Console at the sitemap, or have the website serve it as its own /sitemap.xml.
Page speed
The shop sees how fast its website feels on a Website speed card, from readings its visitors' browsers send. Add Google's web-vitals to the website and send each reading:
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
const send = (metric) => fetch('https://app.debianpos.com/api/v1/performance/metrics', {
method: 'POST',
keepalive: true,
headers: { 'X-Storefront-Key': 'dpk_your_key', 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
metric_name: metric.name,
metric_id: metric.id,
value: metric.value,
delta: metric.delta,
rating: metric.rating,
navigation_type: metric.navigationType,
page_path: location.pathname,
page_url: location.href,
is_mobile: matchMedia('(max-width: 767px)').matches,
viewport_width: innerWidth,
viewport_height: innerHeight,
}),
});
[onCLS, onFCP, onINP, onLCP, onTTFB].forEach((on) => on(send));
Questions, or something the API does not do yet? Write to us.