What you'll build

In this part you'll add a complete order and payment flow. A client calls your API to create an order, gets back a Paystack payment URL, and when the customer completes payment, Paystack calls your webhook so you can mark the order as paid.

By the end of this part:

Create the orders table

create table orders (
  id               uuid primary key default gen_random_uuid(),
  user_id          uuid references users(id),
  items            jsonb not null default '[]',
  total_ghs        numeric(10,2) not null,
  status           text not null default 'pending',
  paystack_ref     text unique,
  payment_channel  text,
  paid_at          timestamptz,
  created_at       timestamptz default now()
);

Get your Paystack secret key

Log in to dashboard.paystack.com, go to Settings → API Keys & Webhooks, and copy your Secret Key (starts with sk_test_ for test mode).

Add it to .env:

PAYSTACK_SECRET_KEY=sk_test_your_key_here

Also in the Paystack dashboard, set the Webhook URL to your server's webhook endpoint. For local testing, use your ngrok URL:

https://your-ngrok-url.ngrok-free.app/webhooks/paystack

Build the orders endpoint

BlocWeave prompt

Add the orders flow to the product catalog API. 1. src/schemas/order.schema.ts — CreateOrderSchema: { items: array of { product_id: string uuid, quantity: number int positive } }. 2. src/controllers/orders.controller.ts — export: - createOrder(req, res, next): requireAuth. Validate items. For each item, fetch the product from Supabase and verify it is in stock. Calculate total_ghs by summing (price_ghs * quantity) for each item. Generate a unique reference: "PCO-" + Date.now() + "-" + crypto.randomBytes(4).toString("hex").toUpperCase(). Insert an order row with status "pending" and paystack_ref set to the reference. Call Paystack POST /transaction/initialize with { email: req.user.email, amount: total_ghs * 100 (convert to pesewas), reference, metadata: { orderId } }. Return 201 with { data: { orderId, paystackUrl: authorizationUrl, reference } }. - getOrder(req, res, next): fetch order by req.params.id. If user is not the order owner and not an admin, return 403. Return the order. 3. src/routes/orders.router.ts — POST / (requireAuth, validate CreateOrderSchema, createOrder), GET /:id (requireAuth, getOrder). Mount under /orders in index.ts.

Build the Paystack webhook handler

The webhook endpoint must NOT use express.json() — Paystack requires access to the raw request body to verify the signature. Set this up carefully:

BlocWeave prompt

Create the Paystack webhook handler for the product catalog API. 1. In src/index.ts, mount the Paystack webhook route BEFORE the global express.json() middleware, using express.raw({ type: "application/json" }) so the raw body is available as req.body (a Buffer). 2. src/controllers/webhooks.controller.ts — export paystackWebhook(req, res, next): - Read the x-paystack-signature header - Compute HMAC-SHA512 of the raw body (req.body.toString()) using PAYSTACK_SECRET_KEY - Compare with the header value using crypto.timingSafeEqual to prevent timing attacks - If mismatch, return 401 { error: "Invalid signature" } - Parse the body: const event = JSON.parse(req.body.toString()) - If event.event === "charge.success": extract the reference from event.data.reference, look up the order by paystack_ref in Supabase, update status to "paid", paid_at to new Date(), payment_channel to event.data.channel - Return 200 immediately after verification (Paystack retries if it doesn't get a 200 within 30s) 3. src/routes/webhooks.router.ts — POST /paystack → paystackWebhook.

Test with a real Paystack transaction

# Create an order (you need a valid product ID and a logged-in user token)
curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer TOKEN" \
  -d '{"items":[{"product_id":"PRODUCT_UUID","quantity":1}]}'

Copy the paystackUrl from the response and open it in a browser. Use the Paystack test card:

After payment, check your server logs for the webhook event and verify the order status changed to paid in Supabase.

Signature verification is non-negotiable. Without it, anyone who knows your webhook URL can send fake payment events. The crypto.timingSafeEqual check also prevents timing attacks that could let an attacker guess your key one bit at a time.

Project structure after Part 4

src/
  controllers/
    orders.controller.ts
    webhooks.controller.ts
  routes/
    orders.router.ts
    webhooks.router.ts
  schemas/
    order.schema.ts

What's next

In Part 5 we deploy the API to Railway, set all environment variables in the Railway dashboard, and verify the live endpoint is working with real Paystack webhooks pointing at the deployed URL.