What you'll build
A Node.js/Express integration with OPay's Cashier Checkout API. By the end of this tutorial you'll have:
- A
POST /pay/opayendpoint that initializes an OPay Cashier session and returns a checkout URL - HMAC-SHA512 request signing using your secret key
- A
POST /webhooks/opayendpoint that checks payment status and updates your database - Support for NGN, GHS, and EGP currencies
OPay operates across Nigeria, Ghana, and Egypt, offering mobile money, bank transfer, and card payments through a single hosted checkout page.
Get your API key
- Sign up at merchant.opayweb.com and complete KYC verification
- Go to Settings > API Keys and copy your Public Key, Secret Key, and Merchant ID
- OPay provides two environments:
- Sandbox:
https://sandboxapi.opayweb.com - Production:
https://api.opayweb.com
- Sandbox:
Add these to your .env:
OPAY_PUBLIC_KEY=OPAYPUB1234567890abcdef
OPAY_SECRET_KEY=OPAYSEC1234567890abcdef
OPAY_MERCHANT_ID=281821120123456
OPAY_BASE_URL=https://sandboxapi.opayweb.com
Start with the sandbox URL during development. Switch OPAY_BASE_URL to https://api.opayweb.com when you go live. The API shape is identical across both environments.
Build the payment endpoint
Every OPay API request requires five headers and an HMAC-SHA512 signature over the raw JSON body.
Create an OPay Cashier integration for a Node.js/Express app.
1. src/services/opay.service.ts — export two functions:
signRequest(body: object, secretKey: string): string — JSON.stringify the body, then compute HMAC-SHA512 using the secretKey. Return the hex digest.
initializeCashier(params: { reference: string, amount: number, currency: "NGN" | "GHS" | "EGP", callbackUrl: string, returnUrl: string, user: { id: string, name: string, email: string, phone: string } }): Promise<{ cashierUrl: string }> — Build the request body:
json { "merchant": { "merchantId": OPAY_MERCHANT_ID, "merchantName": "Your Store" }, "reference": params.reference, "amount": { "total": params.amount, "currency": params.currency }, "callbackUrl": params.callbackUrl, "returnUrl": params.returnUrl, "expireAt": new Date(Date.now() + 30 * 60 * 1000).toISOString(), "userInfo": { "userId": params.user.id, "userName": params.user.name, "userEmail": params.user.email, "userMobile": params.user.phone } }
Generate headers: Authorization "Bearer ${OPAY_BASE_URL}/api/v3/native/cashier/initialize. Return data.cashierUrl from the response.
2. src/controllers/opay.controller.ts — export createOpayPayment(req, res, next):
- Extract amount, currency, userId, userName, userEmail, userPhone from req.body
- Generate a unique reference: "OPAY-" + Date.now() + "-" + crypto.randomBytes(4).toString("hex").toUpperCase()
- Call initializeCashier with the callbackUrl set to your server's webhook URL and returnUrl set to your frontend's order confirmation page
- Save the order to your database with status "pending" and the reference
- Return 201 with { cashierUrl, reference }
3. src/routes/opay.router.ts — POST / → createOpayPayment. Mount under /pay/opay in your main app.
Handle the webhook / verify payment
OPay sends a callback to your callbackUrl when payment completes. You should also actively verify the status using the status endpoint, because callbacks can be delayed or lost.
Create the OPay webhook and status verification handler.
1. src/services/opay.service.ts — add a function:
checkCashierStatus(merchantId: string, reference: string): Promise<{ status: string, amount: number }> — Build the request body: { merchantId, reference }. Sign it with signRequest. POST to ${OPAY_BASE_URL}/api/v3/native/cashier/status with the same five headers (Authorization, MerchantId, Timestamp, Nonce, Content-Type) plus the Signature header. Return { status: data.status, amount: data.amount.total } from the response.
2. src/controllers/opay.controller.ts — add opayCallback(req, res, next):
- Extract the reference from the request body
- Call checkCashierStatus with OPAY_MERCHANT_ID and the reference
- If status === "SUCCESS": update the order in the database to status "paid", save paid_at timestamp
- If status === "FAIL" or "CLOSE": update order status to "failed"
- If status === "PENDING": do nothing — OPay will retry
- Return 200 immediately (OPay expects a fast response)
- Important: always verify via the status endpoint rather than trusting the callback body alone — this prevents forged callback attacks
3. src/routes/opay.router.ts — add POST /callback → opayCallback.
Test it
Start your server and create a test payment:
curl -X POST http://localhost:3000/pay/opay \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "NGN",
"userId": "user-001",
"userName": "Kwame Mensah",
"userEmail": "[email protected]",
"userPhone": "+2348012345678"
}'
The response returns a cashierUrl. Open it in a browser to see OPay's hosted checkout page.
In sandbox mode, OPay provides test credentials on their developer portal for completing test transactions. After payment, the callback hits your /pay/opay/callback endpoint and the order status should update to paid.
To manually verify a payment status:
curl -X POST http://localhost:3000/pay/opay/status \
-H "Content-Type: application/json" \
-d '{ "reference": "OPAY-1719000000000-A1B2C3D4" }'
Always verify server-side. Never trust the client's claim that payment succeeded. The checkCashierStatus call is your source of truth — call it on the callback, and also when the user returns to your site via returnUrl, to catch any edge cases where the callback was delayed.
Project structure after this tutorial
src/
controllers/
opay.controller.ts
routes/
opay.router.ts
services/
opay.service.ts
Environment variables:
| Variable | Description |
|---|---|
OPAY_PUBLIC_KEY |
Public key from OPay dashboard (used in Authorization header) |
OPAY_SECRET_KEY |
Secret key for HMAC-SHA512 signing |
OPAY_MERCHANT_ID |
Your OPay merchant ID |
OPAY_BASE_URL |
https://sandboxapi.opayweb.com or https://api.opayweb.com |
BlocWeave