§ ShopBot — Telegram commerce demo · Architecture — two services, one private network
Payments — the Paystack flow
Payments go through Paystack (a popular Nigerian payment gateway). The flow has two halves: starting a payment, and confirming it. The golden rule is that money — and stock — only move on a confirmation you can cryptographically trust, never on the customer's word that they paid.
1. Starting a payment (initialize)
When the bot (or a customer) asks to pay an order, the backend calls Paystack's
POST /transaction/initialize with the order's amount (in kobo), a unique
reference, an email, and a callback_url pointing at the dashboard's receipt
page. Paystack returns an authorization_url — the hosted checkout page —
which the backend stores on the order and hands back. The customer opens that URL
and pays there; the backend never touches card details.
bot ──POST /api/orders/:ref/pay──► backend ──initialize──► Paystack
◄── authorization_url ──┘
bot ──► customer: "pay here: https://checkout.paystack.com/..."
If Paystack isn't configured (no secret key), /pay returns a clear 503 and
the dashboard footer reads "test keys not set" — the demo runs fine without
payments, you just can't complete a checkout.
2. Confirming a payment (the webhook) — the trust anchor
When the payment succeeds, Paystack's servers call the backend's
POST /api/paystack/webhook. This is the only event the backend trusts to mark
an order paid — and it trusts it because it verifies the signature:
Paystack signs the webhook body with HMAC-SHA512 using your secret key and sends the digest in the
x-paystack-signatureheader. The backend recomputes that HMAC over the raw request body and compares — using a constant-time comparison (crypto.timingSafeEqual) so an attacker can't probe the secret by timing. A bad or missing signature gets a401; nothing happens.
This is why the backend kept the raw body around (see the backend page) — verifying against re-serialized JSON would fail, because the bytes wouldn't match what Paystack signed.
3. What a verified charge.success does
On a valid charge.success for a known order, markOrderPaid runs in one
transaction and is idempotent:
- If the order is already
paid/fulfilled, do nothing and return — so a replayed webhook (Paystack retries, or a duplicate) can't charge stock twice. - Set the order to
paid. - Decrement stock for each line item (
MAX(0, stock - qty)). - Record the payment with the raw event.
- Broadcast
order.paid+stock.changedon the SSE feed, so the dashboard updates live.
The endpoint always returns 200 to Paystack on a valid signature (even for an
already-processed order), which is what Paystack wants — anything else makes it
keep retrying.
Why stock decrements here, not at order time
Stock is checked when the order is created but only removed when payment is confirmed. That keeps unpaid/abandoned orders from locking inventory. The known trade-off: between order and payment, two customers could each be told an item is available — a small over-sell window that's acceptable for a demo and would be closed in production with a short hold/reservation.
How it was proven without real keys
Real Paystack keys belong to the demo's owner, so the payment path was verified
with a mock secret: a test computes the HMAC-SHA512 of a charge.success
payload with that secret and posts it to the live webhook. The checks confirmed
the order flips to paid, stock drops exactly once, a bad signature is
rejected with 401, and a replayed event doesn't double-decrement. The
dashboard e2e then drives this same signed-webhook path end to end against the
live URL.