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 " goes
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 packageId you send is checked against the freshest list.
  • Manual packages still work and are shown after the live ones; a manual package with the same id replaces 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 https address: 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.