Skip to content

Kahawa ​

Kahawa is a coffee-shop menu that takes MOOKHPay. The shop is kahawa-demo, next to this repo. The server functions are the whole integration.

A customer opens the menu, posts quantities, and lands on a hosted checkout. MOOKHPay sends them back to /paid. The shop reads the checkout with its secret key and then shows a receipt.

kahawa-demo/
├── functions/
│   ├── index.ts      GET  /       menu and basket
│   ├── order.ts      POST /order  create the checkout, redirect
│   └── paid.ts       GET  /paid   confirm, then show the receipt
├── lib/menu.ts       menu, prices, helpers
├── public/           styles and the MOOKHPay mark
└── wrangler.toml     MOOKHPAY_API

Prices stay on the server ​

The browser posts quantities. lib/menu.ts is the only place a price lives.

ts
export const MENU: Item[] = [
  { id: "espresso", name: "Espresso", note: "Double shot, Kiambu single origin", amount: 100 },
  { id: "latte", name: "Latte", note: "Steamed milk, a little foam", amount: 200 },
  { id: "mandazi", name: "Mandazi", note: "Two, warm, cardamom sugar", amount: 300 },
];

export const MAX_EACH = 5;

amount is minor units. These prices are KES 1, KES 2, and KES 3 because staging charges real money.

linesFrom keeps known items with a quantity from 1 to 5. totalOf sums amount * qty. describe builds the payer text, such as 2× Espresso, 1× Mandazi.

Create and redirect ​

functions/order.ts handles POST /order.

ts
const order = `order-${crypto.randomUUID().slice(0, 8)}`;
const shop = new URL(request.url).origin;

const res = await fetch(`${env.MOOKHPAY_API}/checkouts`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${env.MOOKHPAY_SECRET_KEY}`,
    "idempotency-key": order,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    amount: totalOf(lines),
    reference: order,
    description: describe(lines),
    return_url: `${shop}/paid`,
  }),
});
const body = (await res.json().catch(() => null)) as
  | { ok?: boolean; description?: string; data?: { url?: string } }
  | null;

if (!res.ok || !body?.data?.url) {
  // show body.description, or `HTTP ${res.status}`
}

let url = body.data.url;
if (env.CHECKOUT_ORIGIN) {
  const u = new URL(url);
  url = env.CHECKOUT_ORIGIN.replace(/\/$/, "") + u.pathname;
}
return Response.redirect(url, 303);
ChoiceWhy
One value for idempotency-key and referenceOne id per order. A retry returns the same checkout. reference comes back on the return URL
amount: totalOf(lines)Price from lib/menu.ts, in minor units
description: describe(lines)Text on the payer page
return_url: ${shop}/paidUses this request's origin, so a preview URL and the live URL both work
res.json().catch(() => null)A non-JSON error page does not crash the function
303The next request is a GET. Back does not post the order again
CHECKOUT_ORIGINOptional. Replaces the origin of data.url so a demo can use a hub preview. Omit it and redirect to data.url

MOOKHPAY_SECRET_KEY exists only in the Pages Function. The menu HTML has no key and no price.

An empty basket does not call the API. The function shows "Your order is empty."

The payer pays ​

The shop writes no payment UI. The customer sees the amount and the description on the hosted page. They cannot change the amount.

Confirm ​

MOOKHPay can send the customer to /paid?checkout=cs_ke_…&reference=order-…&status=completed. functions/paid.ts reads only checkout.

ts
const id = new URL(request.url).searchParams.get("checkout") ?? "";
if (!/^cs_[a-z]{2}_[0-9a-f]{24}$/.test(id)) return notPaid();

const res = await fetch(`${env.MOOKHPAY_API}/checkouts/${id}`, {
  headers: { authorization: `Bearer ${env.MOOKHPAY_SECRET_KEY}` },
});
const body = await res.json().catch(() => null);
const c = body?.data;

if (!res.ok || c?.status !== "completed") return notPaid();

The receipt shows description, amount, reference, and payment.provider_receipt. Every other result is the same "Not paid" page. That includes open, expired, 404, and a failed fetch.

Kahawa does not store orders

It has no database. It does not poll a payer who paid and closed the tab. A reload of /paid can show the receipt again. A shop that ships goods should do both. See Confirming a payment.

Run it ​

You need Node and pnpm. From kahawa-demo:

sh
pnpm install

Create .dev.vars in that directory. Wrangler loads it for local dev. Git ignores it. There is no example file in the repo.

MOOKHPAY_SECRET_KEY=mkp_test_…
sh
pnpm dev

MOOKHPAY_API is a normal variable in wrangler.toml:

toml
[vars]
MOOKHPAY_API = "https://payments-ke-staging.salimia.me/v1/payments/merchant"

Deploy ​

Put the secret in Pages. Do not put it in wrangler.toml or in git.

sh
pnpm secret MOOKHPAY_SECRET_KEY
pnpm deploy

package.json sets CLOUDFLARE_ACCOUNT_ID on those scripts. Pages config does not take account_id. Use the same pattern when your Wrangler login can see more than one account.

Copy the pattern ​

  1. Keep prices on the server. Replace lib/menu.ts with what you sell.
  2. Keep the create, the idempotency key, and the redirect in order.ts. Change description to the text the payer should see.
  3. In paid.ts, fulfill the order when status is completed. Then show your own receipt.

Staging documentation. Staging charges real money. See Environments.