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." }
  • externalUserId in a found reply 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, avatarUrl are 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: false with 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 expiresInMinutes short (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

  1. 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.
  2. 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.
  3. 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). provider picks the channel (KBZPay, WavePay, Visa…, or USDT_TRC20 / USDT_BEP20 for 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:

    kind You get You do
    QR qrCode (a string) render it as a QR image; the buyer scans it in their wallet app
    REDIRECT checkoutUrl send the buyer there (the gateway's hosted page: PIN, OTP, card entry)
    DEPOSIT crypto show crypto.address, the network and the exact crypto.amount USDT
    MANUAL manual.account show the account details; collect the buyer's slip
  • status — PENDING → PAID, or FAILED / EXPIRED / CANCELLED; PAID → REFUNDED (legacy crypto invoices only). A late gateway success after FAILED or EXPIRED still becomes PAID — money moved, so you will be told.

  • livemode — false when the HivePay instance runs in mock mode (no real money). Gate real crediting on livemode: true in 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 a billing block or the create call returns 400.
  • opensHostedPage: true — expect kind: "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 network key); the price must be at least minInvoiceUsd.

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's priceUsd) must be ≥ crypto.minInvoiceUsd. Pending USDT payments expire on the same window as wallets; a deposit that lands late still makes them PAID.
  • 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 status is often still PENDING on 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

  1. Read the raw request body before any JSON parsing — the signature covers the exact bytes.
  2. Verify the signature (below). Reject with 401 if it fails.
  3. Respond 2xx within 10 seconds. Do the heavy work after acknowledging (queue it) if it might be slow.
  4. 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 on id / X-HivePay-Delivery.
  5. Handle payment.paid even if you previously saw payment.failed for 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 201 and a payment.proof_submitted webhook; show "under review".
  • An operator approves → payment.paid; rejects → payment.failed with the reason in failureReason. 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/simulate with { "reference": "HP-…", "status": "SUCCESS" } (or FAIL, 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.paid received, signature valid, user credited once
  • Duplicate: POST /api/v1/payments/:ref/redeliver → second payment.paid → user not credited twice
  • Failed: simulate FAIL → payment.failed, status page shows the reason
  • Late success: simulate FAIL then SUCCESS → ends PAID, credited
  • Double-click: same idempotencyKey twice → same reference, Idempotent-Replayed: true
  • Hosted return: buyer lands on returnUrl with status=PENDING, page polls to PAID
  • 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.

  1. Paste packages/hcoin-ledger/prisma/hcoin-ledger.prisma into your schema.prisma, add wallet Wallet? and chainAddress ChainAddress? to User, run prisma db push.
  2. Copy packages/hcoin-ledger/src into your project (or depend on the package).
  3. examples/nextjs/lib-hcoin.ts — one createCreditService(prisma) and one HivePayClient({ baseUrl, apiKey }).
  4. examples/nextjs/app/api/webhooks/hivepay/route.ts — handleHivePayWebhook(...) verifies, dedupes on hivepay:<reference>, credits.
  5. 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
  • webhookUrl is HTTPS, public, and registered on your project (ask the operator, or check the admin console)
  • Crediting is gated on event === "payment.paid" and livemode === true
  • Crediting is idempotent on data.reference
  • Your status page polls; it never credits from the URL
  • Card channels send billing; you read requiresBilling rather than hard-coding the list
  • You show error.message from 400s to the buyer's screen (e.g. "Wave Pay is temporarily unavailable")
  • You keep reference and metadata.orderId together 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.