What you'll build
A Node.js/Express integration with Ozow, South Africa's instant EFT payment gateway. By the end of this tutorial you'll have:
- A
POST /pay/ozowendpoint that creates a payment request and redirects the customer to their bank's login page - SHA-512 hash generation for request integrity
- A
POST /webhooks/ozowendpoint that verifies Ozow's notification hash and marks orders as paid - Proper handling of success, error, and cancellation redirects
Ozow is bank-to-bank — no card details involved. The customer selects their bank, logs into online banking, and approves the payment. Funds settle within seconds and Ozow notifies your server immediately.
Get your API key
- Register at hub.ozow.com and create a merchant site
- In the Ozow Hub, note your Site Code, Private Key, and API Key
- Ozow provides two environments:
- Sandbox:
https://stagingapi.ozow.com/PostPaymentRequest - Production:
https://api.ozow.com/PostPaymentRequest
- Sandbox:
Add these to your .env:
OZOW_SITE_CODE=ABC-ABC-ABC
OZOW_PRIVATE_KEY=your_private_key_here
OZOW_API_KEY=your_api_key_here
OZOW_BASE_URL=https://stagingapi.ozow.com
Private Key vs API Key — Ozow uses two different credentials. The Private Key is used to generate SHA-512 hashes for payment requests and webhook verification. The API Key is used for REST API calls like fetching transaction status. Never expose either in client-side code.
Build the payment endpoint
Ozow requires a SHA-512 hash computed from specific fields concatenated in a fixed order. The hash proves the request hasn't been tampered with in transit.
Create an Ozow instant EFT integration for a Node.js/Express app.
1. src/services/ozow.service.ts — export two functions:
generateOzowHash(params: { siteCode: string, countryCode: string, currencyCode: string, amount: string, transactionReference: string, bankReference: string, cancelUrl: string, errorUrl: string, successUrl: string, notifyUrl: string, isTest: string, privateKey: string }): string — Concatenate all values in lowercase in this exact order: siteCode + countryCode + currencyCode + amount + transactionReference + bankReference + cancelUrl + errorUrl + successUrl + notifyUrl + isTest + privateKey. Compute SHA-512 hash of the concatenated string. Return the hex digest in lowercase.
createPaymentRequest(params: { amount: number, reference: string, bankReference: string, successUrl: string, errorUrl: string, cancelUrl: string, notifyUrl: string }): Promise { SiteCode: OZOW_SITE_CODE, CountryCode: "ZA", CurrencyCode: "ZAR", Amount: params.amount.toFixed(2), TransactionReference: params.reference, BankReference: params.bankReference, SuccessUrl: params.successUrl, ErrorUrl: params.errorUrl, CancelUrl: params.cancelUrl, NotifyUrl: params.notifyUrl, IsTest: process.env.NODE_ENV === "production" ? "false" : "true", HashCheck: generateOzowHash({ ... all fields plus OZOW_PRIVATE_KEY }) }
POST this as application/x-www-form-urlencoded to ${OZOW_BASE_URL}/PostPaymentRequest. The response contains a URL — return it so the client can redirect the customer.
2. src/controllers/ozow.controller.ts — export createOzowPayment(req, res, next):
- Extract amount and description from req.body
- Generate a unique reference: "OZOW-" + Date.now() + "-" + crypto.randomBytes(4).toString("hex").toUpperCase()
- Use description or reference as the bankReference (this appears on the customer's bank statement)
- Set successUrl, errorUrl, cancelUrl to your frontend routes and notifyUrl to your server webhook
- Save the order to your database with status "pending" and the reference
- Call createPaymentRequest and return 201 with { paymentUrl, reference }
3. src/routes/ozow.router.ts — POST / → createOzowPayment. Mount under /pay/ozow in your main app.
Handle the webhook / verify payment
Ozow sends a POST notification to your NotifyUrl when the payment completes, fails, or is cancelled. You must verify the hash to confirm the notification is authentic.
Create the Ozow webhook verification handler. 1. src/services/ozow.service.ts — add a function: verifyOzowNotification(body: Record<string, string>, privateKey: string): boolean — Ozow sends these fields in the notification: SiteCode, TransactionId, TransactionReference, Amount, Status, Optional1, Optional2, Optional3, StatusMessage, Hash. To verify: concatenate lowercase values in this order: siteCode + transactionId + transactionReference + amount + status + optional1 + optional2 + optional3 + statusMessage + privateKey (use empty string for any missing optional fields). Compute SHA-512 of the concatenated string. Compare the resulting hex digest (lowercase) against the Hash field from the notification (also lowercased). Return true if they match. 2. src/controllers/ozow.controller.ts — add ozowNotify(req, res, next): - Parse the notification body (Ozow sends application/x-www-form-urlencoded or JSON depending on configuration) - Call verifyOzowNotification with the body and OZOW_PRIVATE_KEY - If verification fails, log the attempt and return 400 { error: "Invalid hash" } - If Status === "Complete": find the order by TransactionReference, update status to "paid", save the TransactionId and paid_at timestamp - If Status === "Cancelled" or "Error" or "Abandoned": update the order status to "failed" with the StatusMessage - Return 200 immediately - Important: use crypto.timingSafeEqual for the hash comparison to prevent timing attacks 3. src/routes/ozow.router.ts — add POST /notify → ozowNotify. Ensure this route is accessible without authentication — Ozow's servers need to reach it.
Test it
Start your server and create a test payment:
curl -X POST http://localhost:3000/pay/ozow \
-H "Content-Type: application/json" \
-d '{
"amount": 150.00,
"description": "Order #1234"
}'
The response returns a paymentUrl. Open it in a browser — you'll see Ozow's bank selection page.
In test mode (IsTest: "true"), Ozow provides a simulated banking experience. Select any bank, complete the fake login, and approve the payment. Ozow will:
- POST a notification to your
NotifyUrlwithStatus: "Complete" - Redirect the customer to your
SuccessUrl
Verify the order status updated in your database.
For local development, expose your server with a tunnel so Ozow's notification can reach you:
npx localtunnel --port 3000
# Use the generated URL as the base for your notifyUrl
Hash order matters. The SHA-512 hash is computed from fields concatenated in a specific order. If even one field is in the wrong position, the hash won't match and the payment request will be rejected. Double-check against Ozow's documentation if you're getting hash mismatch errors.
Project structure after this tutorial
src/
controllers/
ozow.controller.ts
routes/
ozow.router.ts
services/
ozow.service.ts
Environment variables:
| Variable | Description |
|---|---|
OZOW_SITE_CODE |
Your site code from the Ozow Hub |
OZOW_PRIVATE_KEY |
Used for SHA-512 hash generation and webhook verification |
OZOW_API_KEY |
Used for REST API calls (transaction status lookups) |
OZOW_BASE_URL |
https://stagingapi.ozow.com or https://api.ozow.com |
BlocWeave