Meku Checkout — developer guide
Sell a Meku business's products on your own website or app, and take payment through a checkout page Meku runs. Every order lands in the business's Meku Business app like any other online order: the stock goes down, the business prepares it, and Meku pays the business out.
You build everything before the Pay button. Meku runs the checkout. You never handle a price, a card or a mobile-money PIN.
Your site Meku
───────── ────
GET /storefront/catalog ─────► products, prices, stock
POST /storefront/checkout/sessions ────► { id: "cs_live_…", url }
redirect the shopper to url ─────► Meku's checkout page
the shopper pays (M-Pesa, Tigo Pesa, Airtel Money, card)
your success_url?session_id=cs_live_… ◄── redirect back
GET /storefront/checkout/sessions/{id} ──► confirm it was paid
POST your webhook URL ◄───── checkout.session.completed, order.status_changed
- Base URL:
https://api.meku.co.tz/api/v1 - Money is whole Tanzanian shillings (integers).
12000is TZS 12,000. - Every response is an envelope:
{ "success": true, "data": …, "error": null, "meta": … }. An error is{ "success": false, "data": null, "error": "A sentence saying what went wrong" }with an HTTP status: 400/422 for a bad request, 401 for a bad key, 403 for a key used where it is not allowed, 404 for something that does not exist, 409 for a conflict (stock, an expired checkout), 429 for too many requests.
Jobs are a separate guide. A business's open jobs go on its own website
through the same keys, and people apply on a page Meku runs: see
/developers/meku-jobs.
1. Keys
The business makes keys in Meku Business → Settings → Website and apps. A key is shown once; if it is lost, the business stops it and makes another.
Publishable pk_… |
Secret sk_… |
|
|---|---|---|
| Where it lives | in the web page or the app — anyone can read it | on your server only |
| Can | read products, start a checkout | the same, plus read the customer and the order behind a checkout |
| Website addresses on the key | which browser origins may use it, and where a checkout may send the shopper back | only where a checkout may send the shopper back |
- Send the key as
Authorization: Bearer <key>(orX-Meku-Store-Key: <key>). - A secret key sent from a browser is refused (403). A browser always
sends an
Originheader on a cross-site request; Meku refuses a secret key on any request that has one. Use it from your server. - Test keys (
pk_test_…,sk_test_…) run the whole flow without moving money or making an order — see §6. A business gets test keys straight away; live keys need the business to be verified by Meku. - A live key must list your website's addresses (
https://myshop.co.tz). A test key may list none.
Which key to use where. A publishable key that lists website addresses
is checked against the request's Origin header, and a request with no
Origin is refused (403). A server never sends Origin, so a live
publishable key does not work from a server, not even to read products.
| Your site | Use |
|---|---|
| Renders pages on a server (Next.js, Laravel, Django, WordPress) | The secret key for every call, reads included. The browser talks to your server, not to Meku, and no Meku key reaches the page |
| Runs only in the browser (a static site, a single-page app with no server) | The publishable key for reads and for starting a checkout. The session read then has no customer or order (§4) |
| Both | The secret key on the server, the publishable key in the browser. Each key has its own list of addresses |
2. Products
GET /storefront/catalog?page=1&per_page=20
Authorization: Bearer pk_live_…
| Query | |
|---|---|
category |
a category slug from GET /storefront/categories |
search |
words in the product name |
on_sale |
true — only products with a sale price |
bookable |
true — only products booked for a date |
updated_since |
ISO 8601 instant — only products changed after it. For a site that keeps its own copy; a full read now and then is how a copy learns what was removed |
page, per_page |
per_page at most 100 |
Each product carries price, sale_price and effective_price — what
the checkout will charge. Show effective_price. A product with options
lists them under variants (on GET /storefront/catalog/{slug}), each with
its own effective_price; the shopper must choose one.
Other reads: GET /storefront/catalog/{slug} (one product, with options),
GET /storefront/categories, GET /storefront/store (the business's name
and logo), GET /storefront/catalog/{slug}/availability?from=&to= (dates
left on a bookable product).
Meku's service fee is added at checkout on top of the products, and the customer pays it. A business that wants to absorb it lowers its price or sets a sale price.
What the reads return
A product in the catalog list:
{
"id": "6f0c…", "name": "Ngorongoro day trip", "slug": "ngorongoro-day-trip",
"price": 450000, "sale_price": 400000, "effective_price": 400000,
"stock_quantity": 0,
"images": ["https://s3.meku.co.tz/…/cover.jpg", "https://s3.meku.co.tz/…/2.jpg"],
"category_id": "a1b2…",
"is_bookable": true, "booking_basis": "fixed",
"updated_at": "2026-10-01T08:00:00Z"
}
| Field | |
|---|---|
images |
image addresses, the cover first. They are served from https://s3.meku.co.tz; a framework that checks image hosts (Next.js images.remotePatterns) must allow it. The list can be empty |
sale_price |
null when there is no sale. A sale counts only when it is above 0 and below price; effective_price already applies this rule |
stock_quantity |
units left. It means nothing for a bookable product: dates are checked with availability instead |
is_bookable, booking_basis |
a product booked for a date. fixed: one date at a flat price. per_night: a stay, priced per night (§3) |
The list does not say whether a product has options. A checkout line for a
product with options is refused without variant_id, so read the product
before you start its checkout.
GET /storefront/catalog/{slug} adds:
| Field | |
|---|---|
description |
plain text with line breaks, typed by the business. Show it as text, never as HTML |
category |
the whole category (below), or null |
variants |
the options: id, name, price, sale_price, effective_price, stock_quantity, image_url, attributes (an object). For a bookable product an option's stock_quantity is how many bookings it takes on one date |
booking_capacity |
how many bookings a bookable product with no options takes on one date |
A product that does not exist, or that the business has stopped selling, answers 404.
The list's meta is { "page": 1, "per_page": 20, "total": 46, "total_pages": 3 }.
A category is id, name, slug (use it in ?category=), parent_id
(null at the top level), sort_order, image_url (may be null) and
name_sw (the Kiswahili name, may be null). The list is not sorted; sort
it by sort_order.
The store (GET /storefront/store) is id, name, slug, logo_url,
phone, email, business_hours, pickup_address, accepting_orders and
bookings_enabled. When accepting_orders is false, Meku's checkout
refuses Pay on a live session ("… is not taking orders right now"), so say so
on the site before the shopper gets there.
Reviews (GET /storefront/reviews?limit=8, up to 20) are the business's
best reviews, for a testimonials section: author, rating (1–5), title,
body, product_name and created_at. title, body and product_name
may be null.
Availability (GET /storefront/catalog/{slug}/availability?from=2026-10-10&to=2026-11-10)
answers with the bookings left on each date:
{ "success": true, "data": { "2026-10-10": 2, "2026-10-11": 0, "…": 1 } }
A date at 0 is full. Pass variant_id for a product with options, since
each option has its own count. The window is at most 366 days. A product
that is not bookable answers {}.
3. Start a checkout
POST /storefront/checkout/sessions
Authorization: Bearer sk_live_…
Content-Type: application/json
{
"line_items": [
{ "product_id": "6f0c…", "quantity": 2 },
{ "product_id": "91ab…", "variant_id": "c2d4…", "quantity": 1 }
],
"success_url": "https://myshop.co.tz/thanks",
"cancel_url": "https://myshop.co.tz/basket",
"client_reference_id": "order-1042",
"customer": { "name": "Asha", "phone": "0712345678", "email": "asha@example.com" },
"metadata": { "basket": "b-88" }
}
| Field | |
|---|---|
line_items |
1 to 20 lines of product_id, optional variant_id, quantity (1–100). No prices — Meku prices every line. A bookable product also takes booking_start (and booking_end for a stay), as YYYY-MM-DD; a checkout holds one booking |
success_url |
where the shopper goes after paying. ?session_id=cs_… is added. For a live key it must be https:// and on one of the key's website addresses |
cancel_url |
optional — the Back to {shop} button |
client_reference_id |
optional, up to 200 characters — your own id, returned everywhere |
customer |
optional — fills the form for the shopper |
metadata |
optional — up to 20 keys, values up to 500 characters, returned everywhere |
fulfilment |
optional — "delivery" or "pickup" to fix it; leave it out to let the shopper choose |
The answer is the session. Send the shopper to data.url.
{ "success": true, "data": {
"id": "cs_live_Q2x…", "url": "https://meku.co.tz/pay/cs_live_Q2x…",
"status": "open", "payment_status": "unpaid", "livemode": true,
"amount_subtotal": 32000, "amount_total": null, "currency": "TZS",
"line_items": [ … ], "client_reference_id": "order-1042",
"expires_at": "2026-10-04T09:12:00Z", …
} }
Bookings
A line for a bookable product (is_bookable: true) follows its own rules:
fixed |
send booking_start only. The price is charged once |
per_night |
send booking_start (check-in) and booking_end (check-out), at least one night apart. The price is charged once per night; the check-out day stays free for the next guest |
| One booking per checkout | a second bookable line is refused: "A checkout can hold one booking." Other products can share the checkout |
| Quantity is always 1 | a booking takes one place on its dates, whatever quantity you send. To sell for a group, the business makes options ("2 people", "Family of 4") with their own prices, and the shopper picks one; a guest counter on your page would not change the price |
| Options | a product with options needs variant_id, booked or not: "'…' has options. Send the variant_id of the one the shopper chose." |
| Full dates | refused with 409 "Those dates are no longer available." Check availability (§2) before you start the checkout, so the shopper is not surprised |
Lines for the same product and option are added together. The error of a
400, 409 or 422 is a sentence written for the shopper, and you can show it as
it is. A 401, 403 or 5xx is a problem with your key or with Meku: log it and
show the shopper a general message.
- Nothing is reserved yet. Stock is checked now so you hear about a shortage early, and taken when the shopper presses Pay.
- A session expires after 24 hours.
- Creating sessions is limited to 60 a minute from one address.
4. The shopper comes back
Meku sends the shopper to success_url?session_id=cs_live_… after the payment
is confirmed. The redirect is not proof — anyone can type that address.
Read the session before you believe it:
GET /storefront/checkout/sessions/cs_live_Q2x…
Authorization: Bearer sk_live_…
status |
|
|---|---|
open |
not paid yet |
complete |
paid |
expired |
24 hours passed with no payment |
payment_status |
|
|---|---|
unpaid · processing · paid · failed |
failed can still become paid if the shopper tries again on the same checkout |
With a secret key the session also carries customer (name, phone,
email) and order (id, status, subtotal, tax, delivery fee, service fee,
total, delivery address, paid time). With a publishable key those two are
null — a static site with no server still learns whether it was paid.
The whole session:
| Field | |
|---|---|
id, url |
the session, and the page to send the shopper to |
status, payment_status |
the tables above |
livemode |
false for a test key |
line_items[] |
product_id, variant_id, name, quantity, unit_price, total, booking_start, booking_end — as Meku priced them |
amount_subtotal |
the lines added up |
amount_total |
what the shopper paid, service fee included, once a live order exists; null before. It stays null on a test checkout, which makes no order — do not show amount_subtotal as the amount paid |
order_id |
the order, once paid |
order |
secret key only: id, status, subtotal, tax_amount, delivery_fee, service_fee, total_amount, fulfilment, delivery_address, paid_at |
customer |
secret key only: name, phone, email |
client_reference_id, metadata |
what you sent |
success_url, cancel_url, currency (TZS), expires_at, completed_at, created_at |
A session id that does not exist answers 404.
5. Webhooks
Add an address in Settings → Website and apps → Webhooks. It must be
https:// on the public internet. The signing secret (whsec_…) is shown
once. A live address hears live checkouts and real orders; a test address
hears test checkouts.
Every message is a POST with a JSON body:
{
"id": "8b6f…",
"type": "order.status_changed",
"created": 1759480000,
"livemode": true,
"data": { … }
}
type |
data |
|---|---|
checkout.session.completed |
the session, as a secret key reads it |
order.status_changed |
{ order, from, to, checkout_session, client_reference_id } — every move of an order that came from the business's own site: paid, preparing, ready_for_pickup, in_delivery, delivered, cancelled, refund_pending, refunded |
webhook.test |
sent by the Send a test button |
Headers
Meku-Event-Id |
the same on every retry — ignore an id you have already handled |
Meku-Signature |
t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256 of "<t>.<raw body>" with your secret |
Answer with any 2xx within 10 seconds. Anything else is retried after
1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours, then given up. Redirects
are not followed.
Check the signature against the RAW body — before any JSON parsing — and refuse a timestamp more than five minutes old.
// Node (Express)
import crypto from "node:crypto";
app.post("/meku/webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Meku-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.MEKU_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const ok = parts.v1 && fresh &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body);
// … handle event.type, once per event.id
res.status(200).end();
});
<?php // PHP
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_MEKU_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', $sig['t'] . '.' . $body, getenv('MEKU_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) $sig['t']) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// … handle $event['type'], once per $event['id']
http_response_code(200);
6. Test mode
With a pk_test_ or sk_test_ key, the session id starts cs_test_ and
Meku's checkout shows a Test mode banner. Pressing the button completes
the session without an order, without touching stock, and without a
payment. The redirect, the session read and checkout.session.completed
(with "livemode": false) all behave exactly as they will live. Any
http:// or https:// success_url is accepted, including
http://localhost:3000.
7. Apps
Open data.url in an in-app browser (SFSafariViewController on iOS, Custom
Tabs on Android). Make success_url an https link your app owns — a
universal link on iOS, an app link on Android — and list its address on the
key. Custom URL schemes (myshop://) are not accepted: any app on the phone
can claim one.
8. No code at all
The Buy button. One script tag with a publishable key, and any element
with data-meku-product opens the checkout:
<script src="https://meku.co.tz/buy.js" data-key="pk_live_…" defer></script>
<button data-meku-product="6f0c…">Buy now</button>
<button data-meku-product="91ab…" data-meku-variant="c2d4…" data-meku-quantity="2"
data-meku-success="https://myshop.co.tz/thanks">Buy two</button>
The page's address must be on the key.
Payment links. In Settings → Payment links a business makes a link for one or more products and pastes it on Instagram, WhatsApp or SMS. No website is needed.
9. What Meku guarantees
- The price is Meku's. Nothing you send can change what a line costs.
- One Pay, one order. Pressing Pay twice resumes the first payment; it never makes a second order. A payment that failed is cancelled and its stock returned before the shopper tries again.
- Stock is taken under lock when the shopper presses Pay, so two shoppers cannot buy the last one.
- The order is the business's. It appears in Meku Business at once, with
the customer's details, and the business moves it from there — every move
is an
order.status_changedwebhook to you.