CODEX // oaoisme wiki

§ ShopBot — Telegram commerce demo · Architecture — two services, one private network

Payments — the Paystack flow

updated 2026-06-27

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-signature header. 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 a 401; 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:

  1. 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.
  2. Set the order to paid.
  3. Decrement stock for each line item (MAX(0, stock - qty)).
  4. Record the payment with the raw event.
  5. Broadcast order.paid + stock.changed on 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.