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:
POST /orderscreates an order and returns a Paystack payment URLPOST /webhooks/paystackreceives signed Paystack events and updates order statusGET /orders/:idlets the client poll for the current order status- Orders are stored in Supabase with status tracking
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
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:
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:
- Card: 4084 0841 1881 8882
- Expiry: any future date
- CVV: any 3 digits
- OTP: 123456
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.
BlocWeave