Developers

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). 12000 is 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> (or X-Meku-Store-Key: <key>).
  • A secret key sent from a browser is refused (403). A browser always sends an Origin header 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_changed webhook to you.