HivePay API reference
Step-by-step integration: INTEGRATION.md · for AI agents: AI-AGENT-INTEGRATION.md · n8n: N8N.md.
All responses are JSON envelopes:
{ "ok": true, "data": … }
{ "ok": false, "error": { "code": "BAD_REQUEST", "message": "…", "details": … } }
Error codes: UNAUTHORIZED 401 · BAD_REQUEST / VALIDATION_ERROR 400 · NOT_FOUND 404 · CONFLICT 409 · GATEWAY_ERROR 502 · SERVICE_UNAVAILABLE 503 · INTERNAL_ERROR 500.
Client API — Authorization: Bearer hp_live_…
GET /api/v1/payment-methods
What this project may offer right now (a rendering hint; every create re-checks).
{
"creditName": "HCoin", "mmkPerCredit": 100,
"packages": [{ "id": "starter", "name": "Starter", "credits": 1000, "bonus": 0, "priceMMK": 5000 }],
"dinger": [{ "key": "KBZPay", "label": "KBZ Pay", "methods": ["QR","PWA"], "requiresBilling": false, "opensHostedPage": false, "logo": null, "description": "…" }],
"manual": [{ "provider": "KBZPay", "label": "KBZ Pay", "accountName": "…", "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 }
}
POST /api/v1/checkout-sessions → 201 (200 + Idempotent-Replayed: true on replay)
Hosted top-up. Mint a link to HivePay's own page and send the buyer there; the webhook fires as usual.
| Field | ||
|---|---|---|
externalUserId |
required | echoed in the webhook |
displayName |
optional | shown to the buyer |
packageId or amountMMK (+creditAmount) |
optional | pin the purchase; omit both and the buyer picks from packages |
allowedGateways |
optional | subset of DINGER · CRYPTO · MANUAL (NOWPAYMENTS is accepted and means CRYPTO) |
provider |
optional | pre-select a channel |
customerName customerPhone customerEmail billing |
optional | as on payments |
returnUrl cancelUrl |
optional | after HivePay's confirmation screen; ?ref=&status= appended |
metadata |
optional | merged into each payment's metadata (+ checkoutSession: <id>) |
idempotencyKey |
recommended | same key ⇒ same session |
expiresInMinutes |
5 – 1440, default 60 |
{ "id": "…", "token": "cs_…", "url": "https://<hivepay>/topup/cs_…", "status": "OPEN", "externalUserId": "user_1", "packageId": null, "amountMMK": null,
"allowedGateways": [], "provider": null, "returnUrl": "…", "cancelUrl": "…", "metadata": {…}, "idempotencyKey": "…",
"payment": null, "paymentCount": 0, "completedAt": null, "expiresAt": "…", "createdAt": "…" }
status is OPEN · COMPLETED (a payment reached PAID) · EXPIRED. payment is the latest attempt { reference, status, gateway, provider, amountMMK, creditAmount, paidAt }. A buyer who switches channel gets a new payment and the previous pending one is CANCELLED (a payment.failed is sent for it).
POST /api/v1/buyer-sessions → 201
Mint a link to the buyer portal for one of your signed-in users. Show it in your app as "My top-ups": the buyer follows it and sees their payments with your project, each one's receipt, and anything that came back to them.
| Field | ||
|---|---|---|
externalUserId |
required | the same id you send when creating a payment |
displayName |
optional | greets them by name |
returnUrl |
optional | where "Back to |
expiresInMinutes |
5 – 1440, default 60 |
{ "url": "https://<hivepay>/me/bs_…", "token": "bs_…", "externalUserId": "user_42", "expiresAt": "…", "createdAt": "…" }
The URL is the capability — anyone holding it sees that buyer's payments with your project, so mint it when the user clicks and keep the expiry short. 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, because that would escalate a single payment into a whole history.
Buyers have no password or account on H Pay — your app already knows who is signed in, which is why the link comes from you.
POST /api/v1/users/lookup
"Who is this?" for your own users, asked from your server. H Pay runs the same check as the Top Up page: it sends a signed user.lookup to your project's userLookupUrl and returns the answer. Use it when you sell your top-ups on your own pages with the widget, and want to show buyers their account before they pay.
| Field | ||
|---|---|---|
query |
required | what the buyer typed: a user id, username or email |
{ "checked": true, "found": true, "externalUserId": "u_8812", "name": "Khant", "username": "khant", "emailMasked": "kh•••@gmail.com", "avatarUrl": null }
{ "checked": true, "found": false, "message": "No account with that username" }
{ "checked": false, "found": true, "externalUserId": "what they typed", "name": null, … }
Mint the checkout session with the externalUserId from the answer, not with what the buyer typed. checked: false means your project has no lookup URL, so nothing was verified; ask the buyer to double-check before paying. If your lookup URL can't be reached, answers non-2xx, or sends something that isn't the JSON above, the call fails with 502 GATEWAY_ERROR; it is safe to retry.
GET /api/v1/checkout-sessions/:id
Same shape.
POST /api/v1/payments → 201 (200 + Idempotent-Replayed: true on replay)
| Field | ||
|---|---|---|
externalUserId |
required | the user's id in YOUR system; echoed in the webhook |
gateway |
DINGER (default) · CRYPTO · MANUAL |
NOWPAYMENTS is the legacy USDT invoice; use CRYPTO |
provider |
channel key/name (KBZPay, Wave Pay, Visa…), a manual account provider, or for CRYPTO the network: USDT_TRC20 · USDT_BEP20 |
|
method |
QR · PWA · PIN · OTP |
wallet/bank/card channels only; forces a method the provider supports |
packageId or amountMMK |
one required | package prices are resolved server-side; ad-hoc amounts price at amountMMK / mmkPerCredit |
creditAmount |
optional override with amountMMK |
your server is trusted |
customerName customerPhone customerEmail |
optional | passed to the gateway |
billing |
{ email, billAddress, billCity, state, country, postalCode } |
required for card rails (MPU, Visa, Master, JCB, MAB Bank) |
idempotencyKey |
recommended | same key on the same project returns the original payment |
metadata |
any JSON | echoed in the webhook |
returnUrl cancelUrl |
optional | per-payment override of the project's redirect URLs |
Response (Payment):
{
"reference": "HP-MU8LBMEQ-YAJR4SHE", "status": "PENDING", "gateway": "DINGER",
"externalUserId": "user_1", "idempotencyKey": "order-42",
"provider": "KBZPay", "providerName": "KBZ Pay", "method": "QR",
"kind": "QR", // QR | REDIRECT | INVOICE | MANUAL
"qrCode": "00020101…", // render as a QR (kind=QR)
"checkoutUrl": null, // open in browser (kind=REDIRECT|INVOICE)
"amountMMK": 5000, "priceUsd": null, "currency": "MMK", "creditAmount": 1100, "packageId": "starter",
"manual": { "account": {…}, "submission": null }, // kind=MANUAL only
"gatewayTransactionNum": "…", "gatewayTransactionId": null,
"expiresAt": "…", "createdAt": "…", "updatedAt": "…",
"receiptUrl": "https://<hivepay>/receipt/HP-…/<sig>" // the buyer's receipt page; show it on your confirmation screen
}
qrCode / checkoutUrl are only returned while PENDING. receiptUrl is a signed, non-expiring link to a buyer-facing receipt (what was paid, to whom, for what, and whether you were told); treat it as the buyer's — anyone with the link can read the receipt.
GET /api/v1/payments/:reference
Poll until status leaves PENDING (PAID · FAILED · EXPIRED · CANCELLED · REFUNDED). The webhook is the authoritative signal; polling is for UI.
GET /api/v1/payments?externalUserId=&status=&gateway=&limit=25&cursor=
Cursor-paginated (nextCursor).
POST /api/v1/payments/:reference/cancel
Closes the attempt here; it does not void anything at the gateway. A buyer
holding the old QR can still pay it, and markPaid accepts CANCELLED (as well
as PENDING, FAILED and EXPIRED) precisely so that money is credited rather
than stranded — so a cancelled payment may still arrive as payment.paid.
PENDING only; refused (409) once a manual slip is under review.
POST /api/v1/payments/:reference/proof → 201
Manual payments: submit the buyer's slip. JSON { payerName, payerPhone, payerReference, buyerNote?, proofUrl, amountMMK? } (amountMMK, if sent, must match the payment ±1 MMK) or multipart/form-data with the same fields plus a proof image (≤ 8 MB; needs BLOB_READ_WRITE_TOKEN). Fires payment.proof_submitted.
POST /api/v1/payments/:reference/redeliver
A fresh event for the payment's current state, sent to every endpoint that wants it (recovery when a receiver was down past the retry window). Returns { deliveryId, event, status, deliveries: [{ id, endpointId, url, status }] }.
Webhook endpoints — /api/v1/webhook-endpoints
A project can have up to 10 endpoints. The primary one is the project's webhookUrl + webhookSecret (the same values you got at registration); it gets every event. Extra endpoints have their own signing secret and may subscribe to a subset of events.
| Method | Path | |
|---|---|---|
| GET | /api/v1/webhook-endpoints |
list — { id, isPrimary, url, label, events, active, paused, failureStreak, lastDeliveredAt, lastFailedAt, lastError, pausedAt, pausedReason, secretPrefix, … }, never the secret |
| POST | /api/v1/webhook-endpoints |
{ url, label?, events?: ["payment.paid" | "payment.failed" | "payment.proof_submitted"], active? } → 201 with secret once |
| GET | /api/v1/webhook-endpoints/:id |
one endpoint + health24h: { delivered, failed, pending, total, successRate } |
| PATCH | /api/v1/webhook-endpoints/:id |
url, label, events, active (changing the primary's url also changes webhookUrl) |
| DELETE | /api/v1/webhook-endpoints/:id |
extra endpoints only |
| POST | /api/v1/webhook-endpoints/:id/test |
{ event? } — sends a synthetic event now; returns { delivered, responseStatus, responseBody, error, durationMs } |
| POST | /api/v1/webhook-endpoints/:id/rotate-secret |
new secret once; deliveries are signed with it immediately |
| POST | /api/v1/webhook-endpoints/:id/resume |
unpause; every held delivery is attempted right away |
Health and pausing. Each endpoint tracks consecutive failed attempts. After 10 in a row spanning at least an hour it is paused automatically: deliveries keep being created (nothing is ever dropped) but are held until it is resumed — from this API, the developer portal, or the console. Operators and the project's Telegram chat (if set) are alerted.
Test events look exactly like real ones except: top-level "test": true, "livemode": false, data.reference starts with HP-TEST-, data.externalUserId is "test_user". They count against nothing and are never retried. Never credit a test event.
Webhook deliveries — /api/v1/webhook-deliveries
| Method | Path | |
|---|---|---|
| GET | /api/v1/webhook-deliveries?status=&event=&endpointId=&reference=&limit=25&cursor= |
your event log, newest first — { id, event, reference, endpointId, url, status, isTest, attempts, maxAttempts, lastResponseStatus, lastError, durationMs, nextAttemptAt, deliveredAt, createdAt } |
| POST | /api/v1/webhook-deliveries/:id/replay |
the same event and payload again, as a new delivery (new id, same data.reference) |
Buyer portal — /me/<token>
Minted by POST /api/v1/buyer-sessions. Phone-first, no sign-in, three tabs: what they topped up, their full history with receipt links, and "money back" — every top-up that did not succeed, saying plainly whether anything was taken. no-store, no-referrer, noindex.
Public storefront — /topup
Projects with storefrontEnabled are listed at /topup; /topup/<slug> asks the buyer for their account id, (username, user id or email) and checks it against the project's userLookupUrl (a signed user.lookup POST with query + queryType — contract in INTEGRATION.md §1c), mints a checkout session and continues on the hosted page. Sessions minted this way have metadata.source = "storefront".
Live packages (packagesUrl)
Instead of typing packages into H Pay, a project can serve them from its own site. Set Packages URL in the portal (Packages) or packagesUrl on the project. H Pay POSTs this, signed like a webhook with your primary secret (x-hivepay-signature, event header packages.list):
{ "event": "packages.list", "project": "your-slug", "createdAt": "2026-10-04T08:00:00.000Z" }
Answer 200 with { "packages": [ { "id": "kc-1000", "name": "1,000 credits", "credits": 1000, "bonus": 0, "priceMMK": 5000, "priceUsd": 1.2, "description": "…", "popular": true } ] }. bonus, priceUsd, description and popular are optional; priceMMK is whole kyat, at least 500. Up to 50 packages; an invalid one is skipped and named in the portal.
- Fresh: the list is refreshed when it's over a minute old and a buyer opens a checkout, on every cron run, and on "Sync now". A
packageIdyou send is checked against the freshest list. - Manual packages still work and are shown after the live ones; a manual package with the same
idreplaces the live one. - If your site is down or answers badly, buyers keep the last good list and the portal shows the error.
- Use the final
httpsaddress: redirects aren't followed.
Widget configurator — /portal/developers/widgets
Pick a widget, set theme/accent/corners, watch the real SDK render it against
fixtures (it creates nothing), copy the HTML / React / server snippet, and set
allowedOrigins. Every control is in the query string, so a configured snippet
is a shareable URL. Saving the origins is recorded as a security event.
Developer portal — /portal
Sign in with the project API key to see endpoints and their health, every delivery with its payload and the receiver's response, the API request log (30 days), payments, and to add/pause/test endpoints, rotate secrets and replay deliveries. Rotating the API key ends portal sessions.
Widget API — the browser, fenced by origin
Called by public/v1/widget.js from a merchant's own page. No API key: the
credential is the checkout-session token in the path, and the fence is
Project.allowedOrigins. A request whose Origin is not listed gets 403 ORIGIN_NOT_ALLOWED with no CORS headers, so the browser can't read the body
either. A request with no Origin at all (curl, a server, a same-origin GET) is
allowed — without one, a browser is not acting on another site's behalf. Empty
allowedOrigins denies every cross-origin browser call.
OPTIONS on each route answers the preflight 204. The
access-control-allow-origin echoed is always the one origin just checked,
never *.
GET /api/v1/widget/session/:session
What a widget needs to draw itself, built from an explicit field list:
{ "merchant": { "name": "Hive DJ", "logoUrl": null },
"creditName": "HCoin",
"packages": [{ "id": "starter", "name": "Starter", "credits": 1000, "bonus": 100, "priceMMK": 5000, "popular": true }],
"fixed": null, // set instead of packages when the session is pinned
"buyer": { "displayName": "Min Thant", "avatarUrl": null },
"channels": [{ "channel": "DINGER:KBZPay:", "label": "KBZ Pay", "kind": "qr", "hosted": false }],
"open": true, "expiresAt": "…", "hostedUrl": "https://<hivepay>/topup/cs_…" }
It deliberately does not carry externalUserId, the merchant's metadata,
or any gateway payload. channel is opaque — send it back as-is.
hosted: true means that channel needs the full page (cards, for the billing
address).
POST /api/v1/widget/session/:session/pay
Body { packageId?, channel }. Runs the same startPayment the hosted page
does, so it inherits idempotency (a double-tap replays), cancel-on-switch, and
catalogue-only pricing. There is no amount field. Returns:
{ "reference": "HP-8F3A2C", "status": "PENDING", "kind": "qr",
"providerName": "KBZ Pay", "amountMMK": 5000, "creditAmount": 1100,
"expiresAt": "…",
"qrSvg": "<svg …>", // rendered server-side; the SDK carries no encoder
"redirectUrl": null, // hosted pages and crypto invoices
"bank": null, // label, accountName, accountNumber, instructions
"usdt": null } // network + pricedAtUsd; the address and exact figure are on the hosted page
GET /api/v1/widget/session/:session/payments/:reference
Status only: { reference, status, amountMMK, creditAmount, paidAt }. The
reference is under the session in the path on purpose — a page holding one token
cannot read another buyer's payment by guessing a reference. A reference this
session didn't start is 404.
GET /v1/widget.js
The SDK. Served with Access-Control-Allow-Origin: * because a module script
loaded cross-origin needs CORS on the file itself; it carries no secrets.
Webhooks — HivePay → your project
POST to every endpoint that subscribes to the event (the project's webhookUrl, plus any endpoints added via /api/v1/webhook-endpoints or the portal), each signed with that endpoint's secret, with headers:
Content-Type: application/json
X-HivePay-Event: payment.paid | payment.failed | payment.proof_submitted
X-HivePay-Delivery: <delivery id>
X-HivePay-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(webhookSecret, "<t>.<raw body>")>
Verify over the raw body, reject if |now - t| > 300s, then dedupe on data.reference (redeliveries and retries reuse the same reference). hcoin-ledger's handleHivePayWebhook does all of this.
{
"id": "<delivery id>", "event": "payment.paid", "createdAt": "…", "livemode": true,
"data": {
"reference": "HP-…", "project": "hivedj", "externalUserId": "user_1", "idempotencyKey": "order-42",
"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": { … },
"paidAt": "…", "failedAt": null, "failureReason": null, "createdAt": "…",
"receiptUrl": "https://<hivepay>/receipt/HP-…/<sig>"
}
}
Respond 2xx within 10 s. Non-2xx / timeout ⇒ retry after 1m, 5m, 15m, 1h, 3h, 6h, 12h, 24h, then FAILED (visible in the admin API; re-queue with /redeliver).
payment.failed is also sent for EXPIRED, CANCELLED and REFUNDED (see data.status / failureReason).
Browser returns
Hosted-page channels (PIN/OTP/cards, crypto invoices) send the buyer to GET /api/return/:reference/(success|cancel), which redirects to payment.returnUrl ?? project.successRedirectUrl (or the fail URL) with ?ref=<reference>&status=<PAID|PENDING|…>&outcome=<success|cancel> appended. The buyer often lands before the callback — your page should poll. A hosted top-up session's returnUrl additionally gets &receipt=<receiptUrl> once paid.
Operator endpoints (admin API, gateway portal configuration) are documented in the repository's docs/API.md.
H Paydocs