Integrating HivePay into your website or app
This guide takes you from nothing to your first confirmed payment. It uses plain HTTP, so it works from any stack — PHP, Python, Node, Go, n8n, or an AI coding agent. If you are on Next.js + Prisma there is a shortcut at the end.
Field-by-field reference: API.md. For an AI agent doing the integration for you: AI-AGENT-INTEGRATION.md. For n8n / no-code: N8N.md.
1. What HivePay does — and what stays yours
HivePay collects money for you through Myanmar's wallets, banks and cards (MMQR and direct channels: KBZ Pay, AYA Pay, Wave Pay, CB Pay, cards and more), USDT (TRC-20 and BEP-20), and manual bank/wallet transfers where a human checks the slip. When a payment is confirmed it POSTs a signed webhook to your server.
HivePay holds no balances. Your users' credits, coins, subscriptions — whatever you sell — live in your database. HivePay tells you money arrived; you decide what to give the user.
your server HivePay gateway
────────── ─────── ───────
POST /api/v1/payments ──────────▶ create payment ─────────────▶ wallet / bank / crypto gateway
◀───── reference, qrCode/checkoutUrl
show the buyer a QR / hosted page / bank details
◀──────────────── callback: paid
◀───── POST <your webhookUrl> payment.paid (HMAC-signed)
credit the user in YOUR database, respond 200
You need three things from the HivePay operator:
| Looks like | Where it goes | |
|---|---|---|
| Base URL | https://www.hivepay.asia |
server config |
| API key | hp_live_… |
server-side secret, sent as Authorization: Bearer |
| Webhook secret | whsec_… |
server-side secret, verifies incoming webhooks |
The API key must never be in a browser, mobile app, or public repo. Every call in this guide is made from your backend.
1b. The fastest path: HivePay's hosted top-up page
If you don't want to build a checkout UI, don't. Mint a checkout session from your server and send the buyer to the URL you get back. HivePay's own page shows your catalogue and every channel you offer, takes the payment (QR, wallet app, card page, USDT, or bank slip), confirms it, and sends the buyer back to you. Your webhook fires exactly as it would for a payment you created yourself.
curl -X POST https://www.hivepay.asia/api/v1/checkout-sessions \
-H "Authorization: Bearer hp_live_…" -H "Content-Type: application/json" \
-d '{
"externalUserId": "user_42",
"displayName": "Khant",
"returnUrl": "https://yoursite.com/wallet",
"cancelUrl": "https://yoursite.com/wallet",
"metadata": { "orderId": "8f3a" },
"idempotencyKey": "user_42:8f3a"
}'
{ "ok": true, "data": { "id": "66f1…", "token": "cs_…", "url": "https://www.hivepay.asia/topup/cs_…", "status": "OPEN", "expiresAt": "…", "payment": null, … } }
Redirect the buyer to data.url. When they're done, HivePay shows its own
"Payment confirmed" screen and then sends them to returnUrl?ref=HP-…&status=PAID.
What you can control on the session:
| Field | ||
|---|---|---|
externalUserId |
required | who to credit — comes back in the webhook |
displayName |
optional | shown on the page ("Topping up Khant") |
packageId |
optional | pin one package; otherwise the buyer picks from the catalogue |
amountMMK (+ creditAmount) |
optional | a fixed ad-hoc amount instead of a package |
allowedGateways |
optional | e.g. ["DINGER"] to hide crypto and bank slips |
provider |
optional | pre-select one wallet — the page becomes a single "Pay" button |
customerName/Phone/Email, billing |
optional | passed to the gateway; billing skips the card address step |
returnUrl, cancelUrl |
recommended | where "Back to your site" goes; ?ref=&status= is appended |
metadata |
optional | merged into every payment's metadata, echoed in the webhook |
idempotencyKey |
recommended | same key → same session (200 + Idempotent-Replayed: true) |
expiresInMinutes |
default 60 | 5 – 1440 |
Rules worth knowing: the session never carries a price — the page prices from
your catalogue like every other create. The link stays usable until it
expires or a payment on it is PAID, so a buyer whose KBZ Pay attempt failed
can retry with Wave Pay. Bank-slip transfer is offered on the hosted page only
when the HivePay instance has upload storage configured. GET /api/v1/checkout-sessions/:id tells you the session's state and its latest
payment, but the webhook remains the signal to credit on.
You still need Section 7 (the webhook). Everything else in this guide is for building your own checkout instead.
1c. Zero-code: let buyers come to H Pay's Top Up page
If you'd rather build nothing at all, switch on the public storefront for
your project (developer portal → Settings, or ask the operator). Buyers open
https://pay…/topup, pick your site, enter their account id, choose a package
and pay. You get the same payment.paid webhook with data.externalUserId set
to that id.
Two settings make it safe:
- Ask the buyer for / where to find it — the label and hint shown on the page ("Your Movie Recap username", "Profile → Account").
- Account lookup URL — so H Pay can check the id with you and show the buyer their name before they pay. Without it, the buyer confirms the id as typed, and a typo tops up the wrong account.
The lookup contract
Buyers can type a username, a user id, or an email — whichever they
remember. H Pay POSTs it to your lookup URL, signed exactly like a webhook
with your primary webhook secret (Section 7 shows how to verify):
POST <userLookupUrl>
X-HivePay-Event: user.lookup
X-HivePay-Signature: t=…,v1=…
{ "event": "user.lookup", "project": "yoursite",
"query": "khant.oo@gmail.com", "queryType": "email", ← "email" | "id" | "username" (H Pay's guess; emails are lower-cased)
"externalUserId": "khant.oo@gmail.com", ← same value, kept for older receivers
"createdAt": "…" }
Match query against all three of your columns (username, id, email) and
reply within 5 seconds:
{ "found": true, "externalUserId": "user_42", "name": "Khant Mu", "username": "khantmu", "email": "khant.oo@gmail.com", "avatarUrl": "https://…/avatar.png" }
{ "found": false, "message": "No account with that username or email." }
externalUserIdin afoundreply is what H Pay uses for the payment and the webhook — return your real user id so a buyer who typed an email is still credited by id. If omitted, the typed value is used.name,username,email,avatarUrlare optional and only shown to the buyer on the confirm step so they can recognise the account. The email is masked before display (kh•••@gmail.com); the full address never reaches the browser.- If several accounts match (say, a username that's also someone's id), reply
found: falsewith a message asking for the email instead. - A non-2xx or unreachable lookup blocks the top-up with a readable error; nothing is minted.
- The URL must be
https://on a public host.
Test it from the portal ("Test the account lookup") before switching the storefront on. Rate limits apply per buyer IP (30 lookups and 20 top-ups per 15 minutes).
1d. Give your buyers their own page — the buyer portal
Your users have no account on H Pay, and they should not need one: your app already knows who is signed in. So you mint the link and they follow it.
curl -X POST https://<hivepay>/api/v1/buyer-sessions \
-H "Authorization: Bearer $HIVEPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "externalUserId": "user_42",
"displayName": "Ma Thida",
"returnUrl": "https://yoursite.com/wallet" }'
# → { "url": "https://<hivepay>/me/bs_…", "expiresAt": "…" }
Show that URL as "My top-ups". The buyer sees:
- every payment they made with your project, newest first,
- the receipt for each one,
- money back — every top-up that did not succeed, saying plainly whether anything was taken and who is holding it.
Rules worth knowing:
- The URL is the capability. Mint it when the user clicks it and keep
expiresInMinutesshort (5 – 1440, default 60). - It is scoped to one project and one
externalUserId. It can never widen to another buyer or another site. - Only your server can mint one. H Pay deliberately never turns a checkout link or a receipt into a portal session — that would escalate a single payment into a whole history.
1e. Receipts — a link you can give a buyer who asks
Every payment has a permanent, signed receipt page. You do not create it: it is
in the API response and in every webhook as receiptUrl, and it is on the
last screen of the hosted checkout.
{ "reference": "HP-8F3A2C", "status": "PAID",
"receiptUrl": "https://<hivepay>/receipt/HP-8F3A2C/ZYH9hK8e_mhEYrR87PWr1yMUGgQ" }
It shows what was paid, to whom, for what, and a four-step "what happened" timeline ending in whether you were told. When a buyer says "I paid and got nothing", this page settles it in one look — and it never expires, so support can send it weeks later. Anyone with the link can read that one receipt, so treat it as the buyer's.
1f. Widgets — a payment inside your own page
If you'd rather not send the buyer anywhere, drop one tag in. The widgets are plain custom elements: no framework, ~9 KB gzipped, everything in a shadow root so your CSS can't reach in and ours can't leak out.
<script type="module" src="https://<hivepay>/v1/widget.js"></script>
<hpay-qr session="cs_…" theme="dark" accent="#5BE9D7" radius="12"></hpay-qr>
| Element | What it does |
|---|---|
<hpay-qr> |
Creates the payment, draws the QR, counts down, and flips to a tick when it settles. |
<hpay-button label="Top up"> |
A button that opens the whole picker in a modal over your page. |
<hpay-checkout> |
The same picker, inline, filling whatever box you give it. |
Shared attributes: session, theme="dark|light", accent (hex), radius
(px), locale="en|my", base (H Pay's origin — defaults to wherever
widget.js was loaded from). Each is also a property that reflects back to the
attribute, so el.theme = "light" and setAttribute("theme", "light") do the
same thing — which is what makes the elements safe to drive from React, Vue or
anything else that assigns props on re-render.
React wrappers are in packages/hpay-react:
HPayQR, HPayButton, HPayCheckout, with onPaid / onExpired / onChange
props instead of events.
The session comes from your server. Mint it exactly as in §1b and pass the token out of the returned URL:
const { data } = await (await fetch(`${HIVEPAY}/api/v1/checkout-sessions`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json",
"Idempotency-Key": `topup:${user.id}:${orderId}` },
body: JSON.stringify({ externalUserId: user.id, displayName: user.name, packageId: "starter" }),
})).json();
const session = data.token; // "cs_…" — NOT data.id
Three things to know before you ship one
- List your domain. Portal → Developers → Widgets → Sites allowed to run these widgets. A widget calls H Pay from your visitor's browser holding only the session token, and that token is in your page for anyone to read — so the domain is the fence: a browser on any site you haven't listed is refused, and empty means no site at all. It is a fence against other pages, not a secret: the token still works from a server and on the hosted page, so treat it like the checkout link it is — short-lived, one buyer, one session.
- Credit from the webhook, never from
hpay:paid. The DOM events (hpay:ready,hpay:paid,hpay:expired,hpay:failed,hpay:open,hpay:close,hpay:change,hpay:redirect) are for your interface — a tick, a refresh, a redirect. A browser can be made to say anything; the HMAC on the webhook cannot. - A widget cannot name an amount. Prices come from your catalogue, or from what you pinned to the session. There is no field for a browser to send one.
Cards need a billing address, which no widget should collect: those channels are
flagged hosted and send the buyer to the hosted page for that one step.
No code at all: a link, or a printed QR
https://<hivepay>/topup/<your-slug>?package=starter opens your top-up page
with that package already chosen. Put it in a bio, a message, or a printed QR on
a table. Portal → Developers → Widgets → Payment link & poster has the link,
a Copy button and a print-ready A5 poster.
2. Concepts in one minute
Project — your app, as registered in HivePay. It has a credit unit (e.g.
HCoin), a price per credit, an optional package catalogue, and the URLs HivePay talks to.Payment — one attempt to pay. Its
reference(HP-XXXXXXXX-XXXXXXXX) is the id you store, poll, and receive in the webhook.externalUserId — your user id. You send it when creating the payment; it comes back verbatim in the webhook. That is how you know whom to credit.
packageId or amountMMK — you either name a package from the project's catalogue (price is resolved server-side) or charge an ad-hoc MMK amount.
gateway / provider / method —
DINGER(MMQR, wallets, banks, cards),CRYPTO(USDT),MANUAL(offline transfer + slip).providerpicks the channel (KBZPay,WavePay,Visa…, orUSDT_TRC20/USDT_BEP20for crypto);method(QR,PWA,PIN,OTP) is optional and rarely needed.kind — how the buyer completes the payment. Branch your UI on this, not on the provider:
kindYou get You do QRqrCode(a string)render it as a QR image; the buyer scans it in their wallet app REDIRECTcheckoutUrlsend the buyer there (the gateway's hosted page: PIN, OTP, card entry) DEPOSITcryptoshow crypto.address, the network and the exactcrypto.amountUSDTMANUALmanual.accountshow the account details; collect the buyer's slip status —
PENDING→PAID, orFAILED/EXPIRED/CANCELLED;PAID→REFUNDED(legacy crypto invoices only). A late gateway success afterFAILEDorEXPIREDstill becomesPAID— money moved, so you will be told.livemode —
falsewhen the HivePay instance runs in mock mode (no real money). Gate real crediting onlivemode: truein production.
3. Calling the API
Every response is a JSON envelope:
{ "ok": true, "data": { … } }
{ "ok": false, "error": { "code": "BAD_REQUEST", "message": "…", "details": … } }
| HTTP | code |
Typical cause |
|---|---|---|
| 401 | UNAUTHORIZED |
missing/invalid API key, or the project is deactivated |
| 400 | BAD_REQUEST, VALIDATION_ERROR |
unknown package, channel switched off, card without billing, amount below 500 MMK |
| 404 | NOT_FOUND |
reference belongs to another project or doesn't exist |
| 409 | CONFLICT |
action not allowed in this status (e.g. cancel after a slip was submitted) |
| 502 | GATEWAY_ERROR |
the gateway rejected the initiation — safe to retry with the same idempotencyKey |
| 503 | SERVICE_UNAVAILABLE |
uploads not configured, gateway killed |
Read error.message; it is written to be shown to a developer.
4. Step 1 — Ask what you can offer right now
curl https://www.hivepay.asia/api/v1/payment-methods \
-H "Authorization: Bearer hp_live_…"
{
"creditName": "HCoin",
"mmkPerCredit": 100,
"packages": [
{ "id": "starter", "name": "Starter", "credits": 1000, "bonus": 100, "priceMMK": 5000, "popular": true }
],
"dinger": [
{ "key": "KBZPay", "label": "KBZ Pay", "methods": ["QR", "PWA"], "requiresBilling": false, "opensHostedPage": false, "logo": null, "description": "…" },
{ "key": "Visa", "label": "Visa", "methods": ["OTP"], "requiresBilling": true, "opensHostedPage": true, "logo": null, "description": "…" }
],
"manual": [
{ "provider": "KBZPay", "label": "KBZ Pay", "accountName": "Hive Innovation Co., Ltd", "accountNumber": "09…", "instructions": "…", "qrImageUrl": "…" }
],
"crypto": { "available": true, "gateway": "CRYPTO", "networks": [{ "key": "USDT_TRC20", "label": "USDT (TRC-20)" }, { "key": "USDT_BEP20", "label": "USDT (BEP-20)" }], "currencies": ["usdttrc20", "usdtbep20"], "mmkPerUsd": 4500, "minInvoiceUsd": 1 }
}
Build your top-up screen from this, not from a hard-coded list:
packages— what to show as buttons. Credits granted =credits + bonus.dinger[]— the wallet/bank/card buttons. Channels the operator or your project has switched off simply aren't in the list.requiresBilling: true(MPU, Visa, Mastercard, JCB, MAB Bank) — you must collect and send abillingblock or the create call returns 400.opensHostedPage: true— expectkind: "REDIRECT"rather than a QR.manual[]— offline options, if the project allows them.crypto.available/crypto.networks— the USDT networks a buyer can pay on (gateway: "CRYPTO",provider:the networkkey); the price must be at leastminInvoiceUsd.
This is a rendering hint. Every create re-checks, so an occasional 400 for a just-disabled channel is normal — show the message and let the buyer pick again.
5. Step 2 — Create the payment (from your server)
curl -X POST https://www.hivepay.asia/api/v1/payments \
-H "Authorization: Bearer hp_live_…" \
-H "Content-Type: application/json" \
-d '{
"externalUserId": "user_42",
"provider": "KBZPay",
"packageId": "starter",
"idempotencyKey": "user_42:order_8f3a",
"metadata": { "orderId": "8f3a", "source": "web" }
}'
201 Created:
{
"ok": true,
"data": {
"reference": "HP-MU8LBMEQ-YAJR4SHE",
"status": "PENDING",
"gateway": "DINGER", "provider": "KBZPay", "providerName": "KBZ Pay", "method": "QR",
"kind": "QR",
"qrCode": "00020101021…",
"checkoutUrl": null,
"amountMMK": 5000, "priceUsd": null, "currency": "MMK",
"creditAmount": 1100, "packageId": "starter",
"externalUserId": "user_42", "idempotencyKey": "user_42:order_8f3a",
"expiresAt": "2026-09-20T12:34:56.000Z",
"createdAt": "…", "updatedAt": "…"
}
}
Store reference against your order. creditAmount is what HivePay will tell
you to grant when it's paid — 1000 + 100 bonus here.
Request fields
| Field | Notes | |
|---|---|---|
externalUserId |
required | your user id, ≤128 chars; echoed in the webhook |
packageId or amountMMK |
one required | packages are priced server-side; ad-hoc credits = amountMMK ÷ mmkPerCredit |
creditAmount |
with amountMMK only |
override the computed credits (your server is trusted); ignored when packageId is given |
gateway |
default DINGER |
DINGER · NOWPAYMENTS · MANUAL |
provider |
wallets/cards/manual | KBZPay, Wave Pay, Visa, … — key or display name, spacing/case forgiven. For MANUAL, matches a configured account; can be omitted when only one exists |
method |
optional | force QR / PWA / PIN / OTP if the provider supports it |
billing |
required for cards | { email, billAddress, billCity, state, postalCode, country? } (country defaults to MM) |
customerName customerPhone customerEmail |
optional | passed to the gateway |
idempotencyKey |
strongly recommended | ≤200 chars, unique per checkout attempt on your side (see below) |
metadata |
optional JSON | echoed in the webhook — put your order id here |
returnUrl cancelUrl |
optional | where hosted-page buyers land afterwards; overrides the project defaults |
Idempotency — do this right
Mint an id once per checkout attempt (e.g. a UUID when the top-up dialog
opens) and send it as idempotencyKey on every retry of that attempt. A
double-click, a timeout, or a retried job then returns the original
payment with 200 and the header Idempotent-Replayed: true, instead of
creating a second one. Don't put a timestamp in the key — that makes every
call unique and defeats the point.
Amount rules
- Wallet, bank and card channels (
DINGER): 500 MMK minimum, whole MMK only (HivePay rounds). - Crypto: the USD price (
amountMMK ÷ mmkPerUsd, or the package'spriceUsd) must be ≥crypto.minInvoiceUsd. Pending USDT payments expire on the same window as wallets; a deposit that lands late still makes themPAID. - Pending wallet/bank/card payments expire after the operator's window (default 3 hours) and become
EXPIRED. Manual payments don't expire; the buyer or you cancel them.
The other channels
Hosted page (PIN/OTP, cards) — kind: "REDIRECT"
{ "externalUserId": "user_42", "provider": "Visa", "packageId": "starter",
"billing": { "email": "a@b.com", "billAddress": "12 Pyay Rd", "billCity": "Yangon", "state": "Yangon", "postalCode": "11181" },
"returnUrl": "https://yoursite.com/topup/status", "idempotencyKey": "user_42:order_8f3b" }
USDT (TRC-20 or BEP-20) — kind: "DEPOSIT"
{ "externalUserId": "user_42", "gateway": "CRYPTO", "provider": "USDT_TRC20", "packageId": "starter", "idempotencyKey": "user_42:order_8f3c" }
The response carries priceUsd and a crypto block:
"crypto": { "network": "USDT_TRC20", "chain": "TRON · TRC-20", "address": "T…", "amount": "11.037", "coin": "USDT" }
Show the buyer the address, the network and exactly amount USDT. The last
digits are unique to this payment and are how it is recognised: an exact
transfer turns PAID on its own, usually a minute or two after the network
confirms, and you get the payment.paid webhook as with any channel. Don't
round it. If the buyer's wallet or exchange takes its fee out of the transfer,
the figure won't match. Send them to the hosted top-up page, where they can
paste the transaction ID; it counts as long as at least priceUsd USDT arrived.
Bank / wallet transfer with slip — kind: "MANUAL"
{ "externalUserId": "user_42", "gateway": "MANUAL", "provider": "KBZPay", "amountMMK": 20000, "idempotencyKey": "user_42:order_8f3d" }
manual.account has the account name, number, instructions and QR image to
show. Section 7 covers the slip.
6. Step 3 — Let the buyer pay
Branch on kind:
QR — turn qrCode into an image with any QR library (qrcode on npm,
endroid/qr-code in PHP, qrcode in Python) and show the amount and channel
next to it. Start polling (Step 5). Show a countdown to expiresAt and a
"start again" button after it passes. qrCode is only present while the
payment is PENDING — don't cache the object and re-render it later.
REDIRECT / INVOICE — send the browser to checkoutUrl (top-level
navigation, not an iframe). When the buyer finishes, the gateway sends them to
HivePay, which redirects to your returnUrl (or the project's success URL) with
?ref=<reference>&status=<PENDING|PAID|FAILED|…>&outcome=<success|cancel>.
The buyer usually arrives before the payment is confirmed, so
statusis often stillPENDINGon that redirect. Your status page must poll (Step 5) and must never credit anything based on the URL — the webhook is the truth.
MANUAL — show manual.account (name, number, qrImageUrl,
instructions) and the exact amountMMK. Then collect the slip (Section 7).
7. Step 4 — Get told when it's paid: the webhook
HivePay POSTs to your project's webhookUrl for every settlement:
POST https://yoursite.com/api/webhooks/hivepay
Content-Type: application/json
X-HivePay-Event: payment.paid
X-HivePay-Delivery: 66f1… ← unique per delivery attempt row
X-HivePay-Signature: t=1758374400,v1=8f1c… ← HMAC-SHA256
{
"id": "66f1…", "event": "payment.paid", "createdAt": "…", "livemode": true,
"data": {
"reference": "HP-MU8LBMEQ-YAJR4SHE", "project": "yoursite",
"externalUserId": "user_42", "idempotencyKey": "user_42:order_8f3a",
"status": "PAID", "gateway": "DINGER", "provider": "KBZPay", "providerName": "KBZ Pay", "method": "QR",
"amountMMK": 5000, "priceUsd": null, "currency": "MMK",
"creditAmount": 1100, "creditName": "HCoin", "packageId": "starter",
"gatewayTransactionNum": "…", "gatewayTransactionId": "…", "gatewayAmount": 5000, "gatewayStatus": "SUCCESS",
"amountMismatch": false,
"customerName": null, "customerPhone": null, "customerEmail": null,
"metadata": { "orderId": "8f3a", "source": "web" },
"paidAt": "…", "failedAt": null, "failureReason": null, "createdAt": "…"
}
}
Events
event |
When | data.status |
|---|---|---|
payment.paid |
money confirmed — credit the user | PAID |
payment.failed |
it won't complete | FAILED, EXPIRED, CANCELLED, or REFUNDED — check status/failureReason |
payment.proof_submitted |
a manual-payment buyer uploaded a slip; a human will review it | PENDING |
Your endpoint must
- Read the raw request body before any JSON parsing — the signature covers the exact bytes.
- Verify the signature (below). Reject with 401 if it fails.
- Respond
2xxwithin 10 seconds. Do the heavy work after acknowledging (queue it) if it might be slow. - Be idempotent on
data.reference. Retries and redeliveries carry the same reference with a different delivery id. Keep a "credited references" table (or a unique constraint) and skip anything you've already handled. Never dedupe onid/X-HivePay-Delivery. - Handle
payment.paideven if you previously sawpayment.failedfor the same reference — a late success wins.
If you respond non-2xx or time out, HivePay retries after 1 m, 5 m, 15 m,
1 h, 3 h, 6 h, 12 h, 24 h, then gives up (visible to the operator, who can
re-queue it). You can also ask for it again yourself:
POST /api/v1/payments/:reference/redeliver.
Verifying the signature
Header: X-HivePay-Signature: t=<unix seconds>,v1=<hex>
expected = HMAC_SHA256(key = webhookSecret, message = t + "." + rawBody) → hex
valid = constant_time_equal(expected, v1) AND |now − t| ≤ 300 seconds
Node / TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyHivePay(secret: string, rawBody: string, header: string | null): boolean {
if (!header) return false;
const p = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]));
const t = Number(p.t);
if (!Number.isFinite(t) || !p.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === p.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1));
}
// Express: app.post("/api/webhooks/hivepay", express.raw({ type: "*/*" }), (req, res) => {
// const raw = req.body.toString("utf8");
// if (!verifyHivePay(process.env.HIVEPAY_WEBHOOK_SECRET!, raw, req.header("x-hivepay-signature"))) return res.sendStatus(401);
// const evt = JSON.parse(raw);
// res.sendStatus(200); // ack first
// if (evt.event === "payment.paid" && evt.livemode) creditUser(evt.data); // idempotent on evt.data.reference
// });
PHP
function verifyHivePay(string $secret, string $rawBody, ?string $header): bool {
if (!$header) return false;
parse_str(str_replace(',', '&', $header), $p); // ["t" => "...", "v1" => "..."]
$t = (int)($p['t'] ?? 0);
if (!$t || empty($p['v1']) || abs(time() - $t) > 300) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $p['v1']);
}
$raw = file_get_contents('php://input');
if (!verifyHivePay(getenv('HIVEPAY_WEBHOOK_SECRET'), $raw, $_SERVER['HTTP_X_HIVEPAY_SIGNATURE'] ?? null)) {
http_response_code(401); exit;
}
$evt = json_decode($raw, true);
http_response_code(200); // ack
if ($evt['event'] === 'payment.paid' && $evt['livemode']) creditUser($evt['data']); // idempotent on reference
Python
import hmac, hashlib, time
def verify_hivepay(secret: str, raw_body: bytes, header: str | None) -> bool:
if not header:
return False
parts = dict(kv.split("=", 1) for kv in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask: raw = request.get_data(); verify_hivepay(SECRET, raw, request.headers.get("X-HivePay-Signature"))
No crypto available (no-code tools) — treat the webhook as a hint and
fetch the truth: GET /api/v1/payments/<data.reference> with your API key,
and credit only if that says PAID. The unguessable reference plus your API
key make this safe. n8n recipe: N8N.md.
A cancelled payment can still arrive as payment.paid
Cancelling closes the attempt on our side; it does not void anything at the
gateway. A buyer who already had the QR open can still pay it. When they do, we
credit it and send you payment.paid for a payment you last saw as
CANCELLED — because the alternative is real money at the gateway with nobody
credited.
So: act on payment.paid whatever the payment's last state was, and don't
treat payment.failed as final for the order. If you no longer want to fulfil
it, refund from your side; the reference and the amount are in the event.
One order, several payments
A buyer can start more than one payment for the same order — on the hosted
page, switching from KBZ Pay to Wave Pay cancels the first attempt and creates
a second; with your own UI, a retry after a failure does the same. All of them
carry your metadata.orderId; each has its own reference. Because failed
deliveries retry with backoff, a payment.failed for the abandoned attempt can
arrive after the payment.paid for the one that succeeded.
So: key payment state on data.reference, and treat payment.failed as
"this attempt failed", never as "this order failed". Never move an order
that has already been credited back to failed. If you show one status per
order, show it as paid the moment any reference for it is paid.
Test events — never credit them
From the developer portal (or POST /api/v1/webhook-endpoints/:id/test) you can
send a synthetic event to your endpoint before anything real happens. It is
signed and shaped exactly like a real event, and it is unmistakable:
{ "id": "…", "event": "payment.paid", "test": true, "livemode": false, "data": { "reference": "HP-TEST-…", "externalUserId": "test_user", … } }
Your handler must treat test: true (or livemode: false in production) as
"log and return 200" — never credit. This is also why the guide gates
crediting on livemode.
More than one endpoint, and the portal
A project can register up to 10 endpoints — your site, an n8n workflow, a
Slack notifier — each with its own signing secret and its own choice of
events. Manage them in the developer portal (https://pay…/portal, sign in
with your API key) or with the /api/v1/webhook-endpoints API. The portal
also shows every delivery with the exact payload and your server's response,
lets you replay any of them, and shows your API request log.
If your endpoint keeps failing (10 consecutive failed attempts over an hour or more) HivePay pauses it: deliveries are held, not dropped, and you're told in the portal (and on Telegram if you gave the operator a chat id). Fix the receiver, press Resume, and everything held is sent.
amountMismatch
If the gateway reports a different amount from what was owed,
amountMismatch is true and gatewayAmount says what actually arrived.
Rare; log it and let a human look before crediting the full amount.
8. Step 5 — Show the result
Poll from your server while your status page is open:
curl https://www.hivepay.asia/api/v1/payments/HP-MU8LBMEQ-YAJR4SHE \
-H "Authorization: Bearer hp_live_…"
Every 2–3 seconds until status !== "PENDING" is fine; stop after
expiresAt. Polling is for the UI. The webhook is what credits the user —
if you only poll, a buyer who closes the tab never gets credited.
// browser → your backend → HivePay (never call HivePay from the browser)
async function waitForPayment(reference) {
for (;;) {
const r = await fetch(`/api/topup/status?ref=${reference}`).then((r) => r.json());
if (r.status !== "PENDING") return r; // PAID | FAILED | EXPIRED | CANCELLED
await new Promise((ok) => setTimeout(ok, 2500));
}
}
Cancel a pending payment (buyer changed their mind):
POST /api/v1/payments/:reference/cancel → status: "CANCELLED" and a
payment.failed webhook. Refused with 409 once a slip is under review.
List a user's payments: GET /api/v1/payments?externalUserId=user_42&limit=25 (cursor-paginated).
9. Manual payments — the slip
After showing the account details, collect from the buyer: their name, phone, the transaction reference from their banking app, and a photo of the slip. Then, from your server:
# JSON with an already-hosted image
curl -X POST https://www.hivepay.asia/api/v1/payments/HP-…/proof \
-H "Authorization: Bearer hp_live_…" -H "Content-Type: application/json" \
-d '{ "payerName": "Mya Mya", "payerPhone": "09777000111", "payerReference": "KBZ-TX-42", "proofUrl": "https://yoursite.com/uploads/slip.jpg", "amountMMK": 20000 }'
# or multipart with the file itself (≤ 8 MB; JPG/PNG/WEBP/HEIC/GIF)
curl -X POST https://www.hivepay.asia/api/v1/payments/HP-…/proof \
-H "Authorization: Bearer hp_live_…" \
-F payerName="Mya Mya" -F payerPhone=09777000111 -F payerReference=KBZ-TX-42 -F proof=@slip.jpg
amountMMK, if you send it, must match the payment (±1 MMK) — it catches a stale page.- You get
201and apayment.proof_submittedwebhook; show "under review". - An operator approves →
payment.paid; rejects →payment.failedwith the reason infailureReason. Show that to the buyer and let them start over. - Multipart uploads need the HivePay instance to have storage configured; if you get 503, host the image yourself and send
proofUrl.
10. Testing before go-live
There is no separate sandbox. Ask the operator for a HivePay instance running
in mock mode (DINGER_MODE=mock). Against it:
- Creating a wallet/bank/card payment returns a fake QR / checkout URL; nothing is charged.
POST /api/dev/dinger/simulatewith{ "reference": "HP-…", "status": "SUCCESS" }(orFAIL,CANCELLED,TIMEOUT) fires a real encrypted callback at HivePay, which then sends your webhook exactly as in production. No auth needed on that dev endpoint; it doesn't exist on live instances.- Webhooks arrive with
livemode: false. - Manual payments work for real: submit a slip, then have the operator approve or reject it in the admin console.
Walk this checklist and you've covered what breaks in production:
- Paid: QR created → simulate
SUCCESS→payment.paidreceived, signature valid, user credited once - Duplicate:
POST /api/v1/payments/:ref/redeliver→ secondpayment.paid→ user not credited twice - Failed: simulate
FAIL→payment.failed, status page shows the reason - Late success: simulate
FAILthenSUCCESS→ endsPAID, credited - Double-click: same
idempotencyKeytwice → samereference,Idempotent-Replayed: true - Hosted return: buyer lands on
returnUrlwithstatus=PENDING, page polls toPAID - Manual: slip submitted →
proof_submitted→ operator approves →payment.paid; reject →payment.failed - Bad signature → your endpoint returns 401 and nothing is credited
- Test event from the portal → 200, nothing credited
- Your endpoint answers in < 10 s even when your database is slow
11. Shortcut for Next.js + Prisma (MongoDB)
packages/hcoin-ledger is a drop-in ledger plus a typed client and webhook
handler. It gives you a wallet per user, idempotent credit/debit, purchases,
refunds, P2P transfers and withdrawals, and it already does everything in
Section 7 correctly.
- Paste
packages/hcoin-ledger/prisma/hcoin-ledger.prismainto yourschema.prisma, addwallet Wallet?andchainAddress ChainAddress?toUser, runprisma db push. - Copy
packages/hcoin-ledger/srcinto your project (or depend on the package). examples/nextjs/lib-hcoin.ts— onecreateCreditService(prisma)and oneHivePayClient({ baseUrl, apiKey }).examples/nextjs/app/api/webhooks/hivepay/route.ts—handleHivePayWebhook(...)verifies, dedupes onhivepay:<reference>, credits.examples/nextjs/app/api/topup/route.ts— create + poll endpoints for your UI.
Full package docs: packages/hcoin-ledger/README.md in the repository.
12. Go-live checklist
- API key and webhook secret are in server-side environment variables only
-
webhookUrlis HTTPS, public, and registered on your project (ask the operator, or check the admin console) - Crediting is gated on
event === "payment.paid"andlivemode === true - Crediting is idempotent on
data.reference - Your status page polls; it never credits from the URL
- Card channels send
billing; you readrequiresBillingrather than hard-coding the list - You show
error.messagefrom 400s to the buyer's screen (e.g. "Wave Pay is temporarily unavailable") - You keep
referenceandmetadata.orderIdtogether in your database for support - Someone on your side watches the developer portal's webhook page (or has the Telegram chat id set) so a paused endpoint is noticed
13. Endpoint quick reference
All with Authorization: Bearer hp_live_….
GET /api/v1/payment-methods |
what to offer now |
POST /api/v1/checkout-sessions |
mint a hosted top-up link (201; replay → 200) |
GET /api/v1/checkout-sessions/:id |
session state + latest payment |
POST /api/v1/payments |
create (201; 200 + Idempotent-Replayed: true on replay) |
GET /api/v1/payments/:reference |
poll |
GET /api/v1/payments?externalUserId=&status=&gateway=&limit=&cursor= |
list |
POST /api/v1/payments/:reference/cancel |
cancel while pending |
POST /api/v1/payments/:reference/proof |
submit a manual-payment slip |
POST /api/v1/payments/:reference/redeliver |
send the webhook again |
POST /api/v1/buyer-sessions |
mint a "My top-ups" link for one of your users |
Called by the widgets from the browser with the session token instead of a key, and only from an origin the project lists:
GET /api/v1/widget/session/:session |
merchant, packages, channels |
POST /api/v1/widget/session/:session/pay |
{ packageId?, channel } → the attempt to show |
GET /api/v1/widget/session/:session/payments/:reference |
poll that one attempt |
Everything else — field limits, the full Payment object, admin endpoints — is in API.md.
H Paydocs