DEV Community

Bright Londa
Bright Londa

Posted on AI-assisted

Pamsika: how a photo of a price board became a DevDraft Best Builder win at Hack Day Lusaka

Pamsika means "at the market" in Nyanja. We built it last weekend at Hacktoberfest Hack Day Lusaka, and it won Best Builder from DevDraft 🏆

One photo of a price board becomes a digital shop with a payment link for every product, and every published board feeds a public price board for the city.

Thank you to Parity and Tech Kaizen for organising the Hack Day, to MLH for making events like this happen, and to DevDraft for the award.

The problem

Walk through Soweto or Kamwala market in Lusaka and prices live on chalk boards. Sales live in the trader's head, and "send to my number" mobile-money payments leave no receipt. Customers can't order ahead, and shoppers have no way to see what mealie meal or cooking oil costs at the next market today.

Almost everyone already has a phone camera and mobile money. So the whole product had to be one action: snap the board.

How it works

  1. Snap. The trader photographs their board, or a product held next to its price.
  2. Check. Gemma 4 turns the photo into a catalogue and flags anything that looks wrong. One tap fixes it.
  3. Get paid. Publishing creates a product and a mobile-money payment link per item, plus a stall QR to print or share on WhatsApp. When a customer pays, a signed webhook lights up a "Payment received" banner on the seller's phone.
  4. Public good. Every published board adds today's staple prices to an anonymised Market Price Watch, market by market, with the change since yesterday.

Gemma 4 as the eyes

We call gemma-4-26b-a4b-it through the Gemini API with the photo and a prompt that asks for strict JSON, then validate it with zod and retry once with the validation error if it doesn't parse. Two prompt rules did most of the work:

- "name": short product name in Title Case. When a product's packaging is visible,
  name it from the label (brand and flavour, e.g. "Fanta Orange"), never use the
  price itself as the name.
- "flag_reason": a short, friendly sentence for the seller when the price looks
  like a likely typo for that item (e.g. a missing zero) ... else null.
Enter fullscreen mode Exit fullscreen mode

The first version only knew about chalk boards. When a teammate photographed a Fanta bottle next to "K29", Gemma named the item "K29" and flagged it as "not a price board". Telling it that product photos are valid, and to name items from packaging, fixed it: the same photo now reads as Fanta Orange · 500ml · K29.

On a test board with a deliberate typo, it returned:

"K5 for kapenta looks low. Did you mean K50?"

We also back the model with a small deterministic check: known staples have a plausible price range, and a price that is 10× too small gets a suggested fix even if the model misses it.

Google's API sometimes returns a 500. We hit one live in front of the trader screen. Now transient errors retry once on the same model, then fall back to gemma-4-31b-it:

const models = [config.gemmaModel, config.gemmaModel, FALLBACK_MODEL[config.gemmaModel]];
for (const [i, model] of models.entries()) {
  try {
    return (await ai().models.generateContent({ model, contents, config: genConfig })).text ?? "";
  } catch (err) {
    if (!isTransient(err)) throw err;             // 500/503/429 only
    await new Promise((r) => setTimeout(r, 600 * (i + 1)));
  }
}
Enter fullscreen mode Exit fullscreen mode

Getting paid with DevDraft

DevDraft handles the money side. When a seller publishes their board, Pamsika creates a DevDraft product and a payment link for each item, with allowMobilePayment switched on so customers can pay from their phones. The stall QR code points at those links.

When a customer pays, DevDraft sends an HMAC-signed webhook. The receiver verifies the signature over the raw body with a constant-time compare, stores each event id once, and matches the payment to the right stall item by looking for any of our link or product ids anywhere in the payload:

export function verifySignature(rawBody: string, signature: string | null, secret: string) {
  if (!signature || !secret) return false;
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(signature.trim().replace(/^sha256=/, ""));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Enter fullscreen mode Exit fullscreen mode

Live updates reach the seller by server-sent events when the app runs as one server. On Netlify, functions don't share memory, so the sales screen also polls every 3 seconds and announces any payment id it hasn't seen yet. Both paths feed the same banner.

Stack

Next.js (App Router) · TypeScript · Tailwind CSS · Zustand · Turso (libSQL) · Netlify · Gemini API (Gemma 4) · DevDraft API. MIT licensed.

What I'd do differently

  • Test the real photos people will take, early. Our prompt was tuned for chalk boards, but the first real photo was a product next to a price on a laptop screen.
  • Don't trust the API docs blindly. The docs list a products endpoint that the live API answered with "Cannot GET", so publishing falls back to amount-based payment links.

Posts that helped

Built with help from Claude Code.

Top comments (0)