Accept payments on your website & app
This guide is for checkout pages of online stores, food-ordering apps, booking systems… – anywhere the customer pays an order by bank transfer.
The flow
Section titled “The flow”Customer clicks "Pay" → Your server: POST /payment_requests (amount, externalOrderRef = your order id) → Checkout page: QR + receiver + amount + countdown → Customer transfers with their banking app → Hensa: webhook payment_request.paid → your server confirms the order → Checkout page: switches to "Paid" (the browser asks YOUR server, not Hensa)1. Create a request when the customer picks bank transfer
Section titled “1. Create a request when the customer picks bank transfer”// Node.js 18+ (built-in fetch)const res = await fetch('https://api.hensa.example/v1/payment_requests', { method: 'POST', headers: { Authorization: `Bearer ${process.env.HENSA_API_KEY}`, 'Idempotency-Key': crypto.randomUUID(), // reuse the same key when you retry 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: 150000, externalOrderRef: 'DH-1024' })})if (!res.ok) throw new Error((await res.json()).error.code)const paymentRequest = await res.json()console.log(paymentRequest.referenceCode, paymentRequest.qrPayload)- Set
externalOrderRefto your order id – it comes back in the webhook so you find the order instantly. - One order = one request. Customer reloads the page? Reuse the still-valid request instead of creating a new one.
- Retry the same creation with the same
Idempotency-Key.
2. Show it to the customer
Section titled “2. Show it to the customer”The quickest way: redirect the customer to the hosted Hensa Pay page at payUrl – it already has the QR, open-banking-app buttons on mobile, a countdown, live status and a receipt. You still confirm the order with the webhook in step 3.
res.redirect(303, paymentRequest.payUrl)Rendering it on your own page: a good bank-transfer checkout always has:
- The QR code drawn from
qrPayload, large enough to scan from another phone. - The receiver name (
receiver.name) and bank – customers check it before paying, which defeats QR swapping. - The amount and the transfer description (
referenceCode) with copy buttons – for customers who can’t scan. - A countdown to
expiresAt.
3. Confirm the order with the webhook – not the browser
Section titled “3. Confirm the order with the webhook – not the browser”Only treat an order as paid when your server receives payment_request.paid with a valid signature. Don’t trust the browser saying “I paid” – customers close tabs, networks drop, and browsers can be spoofed.
import express from 'express'import { verifyHensaSignature } from './verify-hensa-signature.js'
const app = express()
// Use the RAW body – parsing JSON first changes the bytes and breaks the signature.app.post('/webhooks/hensa', express.raw({ type: 'application/json' }), async (req, res) => { const raw = req.body.toString('utf8') if (!verifyHensaSignature(raw, req.get('Hensa-Signature'), process.env.HENSA_WEBHOOK_SECRET)) { return res.sendStatus(400) } const event = JSON.parse(raw)
// Idempotent: the same event id can arrive more than once. if (await db.processedEvents.exists(event.id)) return res.sendStatus(200)
if (event.type === 'payment_request.paid') { await orders.markPaid(event.data.externalOrderRef, event.data.id) } await db.processedEvents.insert(event.id) res.sendStatus(200) // answer within 10 s – do slow work in a queue})The checkout page just asks your server every few seconds (or over your own WebSocket/SSE) whether the order has changed status.
4. When things don’t go smoothly
Section titled “4. When things don’t go smoothly”| Situation | You receive | Do |
|---|---|---|
| 15 minutes pass unpaid | payment_request.expired |
Release reserved stock; let the customer create a new one |
| Customer underpays | payment_request.partially_paid (plus bank_transaction.created) |
Ask for the remainder, or handle manually |
| Customer pays after expiry | bank_transaction.created (pending) |
Reconcile manually |
| Customer types the wrong description | bank_transaction.created (pending) |
Reconcile manually |
| Your webhook is down for hours | Hensa retries for ~45 hours | Combine with periodic reconciliation |