HivePay integration spec for AI coding agents
If you are a person: give this file to your AI coding agent (Claude Code, Cursor, Copilot, ChatGPT, …) together with the three inputs below and the sentence "Integrate HivePay top-ups into this project following this spec." The agent has everything it needs; you only supply the values.
If you are an agent: follow this spec exactly. Where it says MUST, a deviation is a bug that loses or double-counts money. Human-readable background is in
INTEGRATION.md; the full field reference is inAPI.md.
Inputs (ask the human if missing)
HIVEPAY_URL https://www.hivepay.asia base URL, no trailing slash
HIVEPAY_API_KEY hp_live_… server-side secret
HIVEPAY_WEBHOOK_SECRET whsec_… server-side secret
PUBLIC_WEBHOOK_URL https://<their site>/api/webhooks/hivepay where you will receive events
Also determine from the codebase: the language/framework, how users are
identified (this becomes externalUserId), where a "credit"/"coin"/"balance"
lives, and how secrets are configured. Never hard-code the two secrets. Never
expose the API key to a browser or mobile client.
Choose the path
Path A — hosted page (default unless the human asks for a custom checkout).
You build: (1) a server endpoint that mints a checkout session and redirects the
buyer to data.url; (2) the webhook endpoint; (3) a "welcome back" page at
returnUrl that reads ?ref=&status= and shows the balance (never credits).
Skip items 1, 3 and 4 of Path B. Contract:
POST /api/v1/checkout-sessions (Authorization: Bearer <HIVEPAY_API_KEY>)
{ externalUserId: string; // REQUIRED
displayName?: string; packageId?: string; amountMMK?: number; creditAmount?: number;
allowedGateways?: ("DINGER"|"CRYPTO"|"MANUAL")[]; provider?: string;
customerName?; customerPhone?; customerEmail?; billing?;
returnUrl?: string; cancelUrl?: string; metadata?: object;
idempotencyKey?: string; // MUST be <userId>:<checkoutAttemptId>
expiresInMinutes?: number } // 5–1440, default 60
→ 201 { ok, data: { id, url, status: "OPEN"|"COMPLETED"|"EXPIRED", payment: {reference,status,…}|null, expiresAt, … } }
GET /api/v1/checkout-sessions/:id → same shape (state only; credit on the webhook, not on this)
The buyer returns to returnUrl?ref=<reference>&status=<status>; status may still be PENDING.
Path B — your own checkout UI. Everything below.
What you are building (Path B)
- A server endpoint or function that creates a payment and returns to the UI what the buyer needs (
kind,qrCode,checkoutUrl,manual,reference,amountMMK,creditAmount). - A webhook endpoint at
PUBLIC_WEBHOOK_URLthat verifies the signature, acknowledges fast, and credits the user once per reference. - A status endpoint the UI polls, proxying
GET /api/v1/payments/:reference. - UI that branches on
kind(QR image / redirect / manual instructions) and polls. - A persisted
credited_referencesset (table, unique index, or equivalent) used by (2).
Contracts
Auth and envelope
All requests: Authorization: Bearer <HIVEPAY_API_KEY>, JSON bodies.
All responses: { ok: true, data } or { ok: false, error: { code, message, details? } }.
Non-2xx → surface error.message. 502 GATEWAY_ERROR is retryable with the same idempotencyKey.
GET /api/v1/payment-methods
{
creditName: string; mmkPerCredit: number;
packages: { id: string; name: string; credits: number; bonus: number; priceMMK: number; priceUsd?: number; description?: string; popular?: boolean }[];
dinger: { key: string; label: string; methods: ("QR"|"PWA"|"PIN"|"OTP")[]; requiresBilling: boolean; opensHostedPage: boolean; logo: string|null; description: string }[];
manual: { provider: string; label: string; accountName: string; accountNumber: string; instructions: string|null; qrImageUrl: string|null }[];
crypto: { available: boolean; gateway: "CRYPTO"; networks: { key: "USDT_TRC20"|"USDT_BEP20"; label: string }[]; currencies: string[]; mmkPerUsd: number; minInvoiceUsd: number };
}
Build the channel/package UI from this at request time. Do not hard-code channels.
POST /api/v1/payments → 201 (or 200 + header Idempotent-Replayed: true)
Request (JSON):
{
externalUserId: string; // REQUIRED, ≤128, your user id
packageId?: string; // one of packageId | amountMMK REQUIRED
amountMMK?: number; // integer MMK; minimum 500 for wallet/bank/card channels
creditAmount?: number; // only with amountMMK; overrides amountMMK / mmkPerCredit
gateway?: "DINGER" | "CRYPTO" | "MANUAL"; // default DINGER; CRYPTO takes provider "USDT_TRC20" | "USDT_BEP20"
provider?: string; // channel key/name ("KBZPay","Wave Pay","Visa"…) or manual account provider
method?: "QR" | "PWA" | "PIN" | "OTP"; // optional
billing?: { email: string; billAddress: string; billCity: string; state: string; postalCode: string; country?: string }; // REQUIRED when dinger[].requiresBilling
customerName?: string; customerPhone?: string; customerEmail?: string;
idempotencyKey?: string; // ≤200; MUST be stable per checkout attempt (see rules)
metadata?: Record<string, unknown>; // echoed in webhook
returnUrl?: string; cancelUrl?: string; // absolute URLs; hosted-page return targets
}
Response data (the Payment object; same shape from GET):
{
reference: string; // "HP-XXXXXXXX-XXXXXXXX" — persist this
status: "PENDING"|"PAID"|"FAILED"|"EXPIRED"|"CANCELLED"|"REFUNDED";
gateway: "DINGER"|"CRYPTO"|"NOWPAYMENTS"|"MANUAL"; provider: string; providerName: string; method: string|null;
kind: "QR"|"REDIRECT"|"INVOICE"|"MANUAL"|null;
qrCode: string|null; // only while PENDING
checkoutUrl: string|null; // only while PENDING
amountMMK: number; priceUsd: number|null; currency: "MMK"|"USD"; creditAmount: number; packageId: string|null;
externalUserId: string; idempotencyKey: string;
manual?: { account: { provider; label; accountName; accountNumber; instructions; qrImageUrl } | null;
submission: { status: "PENDING"|"APPROVED"|"REJECTED"; payerName; payerPhone; payerReference; proofUrl; submittedAt; reviewedAt; adminNote } | null };
gatewayTransactionNum: string|null; gatewayTransactionId: string|null; gatewayAmount: number|null;
customerName: string|null; customerPhone: string|null; customerEmail: string|null; metadata: unknown;
paidAt: string|null; failedAt: string|null; failureReason: string|null; expiresAt: string|null;
createdAt: string; updatedAt: string;
}
UI branching MUST be on kind:
QR→ renderqrCodestring as a QR image; poll.REDIRECT|INVOICE→ top-level navigate tocheckoutUrl; on return, poll.MANUAL→ showmanual.account; collect slip;POST /proof.
GET /api/v1/payments/:reference — poll until status !== "PENDING".
POST /api/v1/payments/:reference/cancel — PENDING only; 409 once a slip is under review.
POST /api/v1/payments/:reference/proof → 201
JSON { payerName, payerPhone, payerReference, buyerNote?, proofUrl, amountMMK? } or
multipart/form-data with the same fields plus file proof (≤8 MB image).
amountMMK, if sent, must equal the payment's within ±1.
POST /api/v1/payments/:reference/redeliver — re-send the webhook for a settled payment.
Browser return (hosted pages)
Buyer lands on returnUrl (or the project's configured URL) with query
ref=<reference>&status=<current status>&outcome=<success|cancel>.
status is frequently still PENDING. The page MUST poll and MUST NOT credit from the URL.
Webhook: HivePay → PUBLIC_WEBHOOK_URL
POST, Content-Type: application/json
X-HivePay-Event: payment.paid | payment.failed | payment.proof_submitted
X-HivePay-Delivery: <delivery id — unique per attempt row; NOT for dedupe>
X-HivePay-Signature: t=<unix seconds>,v1=<hex>
Body:
{
id: string; event: "payment.paid"|"payment.failed"|"payment.proof_submitted"; createdAt: string;
livemode: boolean; // false on mock instances
data: {
reference: string; project: string; externalUserId: string; idempotencyKey: string;
status: "PENDING"|"PAID"|"FAILED"|"EXPIRED"|"CANCELLED"|"REFUNDED";
gateway; provider; providerName; method;
amountMMK: number; priceUsd: number|null; currency: string;
creditAmount: number; creditName: string; packageId: string|null;
gatewayTransactionNum; gatewayTransactionId; gatewayAmount: number|null; gatewayStatus: string|null;
amountMismatch: boolean;
customerName; customerPhone; customerEmail; metadata: unknown;
paidAt: string|null; failedAt: string|null; failureReason: string|null; createdAt: string;
}
}
Signature verification (MUST):
rawBody = exact request bytes, read BEFORE any JSON parsing / body middleware
{t, v1} = parse header "t=<t>,v1=<v1>"
expected = hex( HMAC_SHA256( key = HIVEPAY_WEBHOOK_SECRET, msg = t + "." + rawBody ) )
valid = constant_time_equal(expected, v1) && abs(now_unix - t) <= 300
Handler algorithm (MUST):
if !valid → 401, stop
evt = parse(rawBody)
respond 200 now (or within 10 s; defer slow work to a queue/background task)
if evt.test === true → log and stop (synthetic event from the portal; reference starts with HP-TEST-)
switch evt.event:
"payment.paid":
if evt.livemode is false and running in production → log, do not credit
if evt.data.reference ∈ credited_references → stop (duplicate; this is normal)
atomically: insert evt.data.reference into credited_references AND credit
evt.data.creditAmount of evt.data.creditName to user evt.data.externalUserId
(if evt.data.amountMismatch → also flag for human review)
"payment.failed":
record that ATTEMPT evt.data.reference failed (evt.data.status, evt.data.failureReason); do not credit.
One order (metadata.orderId) can have several attempts, and this event can arrive AFTER the
paid one for a sibling attempt — NEVER downgrade an order that already has a credited reference.
"payment.proof_submitted":
mark the order "under review"
Retries: non-2xx/timeout → 1m, 5m, 15m, 1h, 3h, 6h, 12h, 24h, then stops.
A payment.paid can arrive after a payment.failed for the same reference (late success). Credit it.
Optional: endpoints and the event log (same auth)
GET/POST /api/v1/webhook-endpoints list / add { url, label?, events?, active? } → 201 with `secret` once
GET/PATCH/DELETE /api/v1/webhook-endpoints/:id
POST /api/v1/webhook-endpoints/:id/test { event? } → { delivered, responseStatus, error } (sends test:true event)
POST /api/v1/webhook-endpoints/:id/rotate-secret · /resume
GET /api/v1/webhook-deliveries?status=&event=&endpointId=&reference=&limit=&cursor=
POST /api/v1/webhook-deliveries/:id/replay
Use /test as the last step of the integration to prove the endpoint verifies signatures and returns 200 without crediting.
Optional: account lookup for the public storefront
If the human wants buyers to use H Pay's /topup page directly, also build:
POST <your lookup URL> signed like a webhook (same verification, primary secret), X-HivePay-Event: user.lookup
body { event: "user.lookup", project, query: <typed>, queryType: "email"|"id"|"username", externalUserId: <typed>, createdAt }
MUST match `query` against username, user id AND email (emails arrive lower-cased)
reply { found: true, externalUserId: <your user id — REQUIRED for correct crediting>, name?, username?, email?, avatarUrl? }
or { found: false, message?: string } within 5 s; https only; H Pay masks `email` before showing it
Then set the URL (and the id label/hint) in the portal and switch the storefront on.
Rules (MUST)
- API key only on the server. Webhook secret only on the server.
- Dedupe crediting on
data.reference, never onidor the delivery header. - Credit only on
event === "payment.paid"; the URL, a poll, orpayment.proof_submittednever credit. idempotencyKey=<userId>:<checkoutAttemptId>where the attempt id is minted once client-side per attempt and reused on retries. No timestamps.- Read
requiresBillingfrom/payment-methods; sendbillingfor those channels. 5b. Order state is derived from references: an order is paid once any of its references is PAID, and stays paid. - Read the raw body for signature verification; frameworks that pre-parse JSON must be configured to expose raw bytes for this route.
qrCode/checkoutUrlexist only whilePENDING— don't cache the Payment object for rendering.- Store
referenceon the order; put your order id inmetadata.
Acceptance tests (run against a mock-mode HivePay; the operator provides one)
POST <HIVEPAY_URL>/api/dev/dinger/simulate { "reference": "<ref>", "status": "SUCCESS" | "FAIL" } (no auth; mock instances only) triggers a real callback and therefore your webhook.
- Create (KBZPay, packageId) →
201,kind: "QR",qrCodenon-null. SimulateSUCCESS→ webhookpayment.paidreceived, signature valid → user balance +=creditAmount, exactly once. POST /redeliveron that reference → secondpayment.paid→ balance unchanged.- Create → simulate
FAIL→payment.failed, order marked failed, balance unchanged. Then simulateSUCCESS→payment.paid, credited. - Same
idempotencyKeytwice → samereference, second response200withIdempotent-Replayed: true. - Tampered body or wrong secret → your endpoint returns
401, nothing credited. 5b.POST /api/v1/webhook-endpoints/<primary id>/test→delivered: true, your endpoint returned 200, nothing credited. - Create with
gateway: "MANUAL"→kind: "MANUAL",manual.accountnon-null.POST /proof→201andpayment.proof_submittedreceived. - Create with a card provider and no
billing→400surfaced as a readable message; withbilling→kind: "REDIRECT",checkoutUrlnon-null. - Your webhook endpoint responds in < 10 s with the database made slow or unavailable (ack before work).
Acceptance tests — Path A
- Mint a session →
201,urlstarts with<HIVEPAY_URL>/topup/cs_. SameidempotencyKeyagain →200+Idempotent-Replayed: true, sameid. - Open
urlin a browser: the project's packages and channels are shown. Pick a wallet → for QR channels a QR appears; for hosted channels the browser goes to the gateway. POST /api/dev/dinger/simulatewith the session's latestpayment.referenceandSUCCESS→ your webhook receivespayment.paid→ user credited once; the page shows "Payment confirmed" and returns toreturnUrl?ref=…&status=PAID.GET /api/v1/checkout-sessions/:id→status: "COMPLETED".
Deliverable checklist
- env/config entries for
HIVEPAY_URL,HIVEPAY_API_KEY,HIVEPAY_WEBHOOK_SECRET - create-payment server function/endpoint (never callable with a client-supplied price — the client sends
packageIdor the server decidesamountMMK) - webhook endpoint at
PUBLIC_WEBHOOK_URLimplementing the handler algorithm -
credited_referencespersistence with a uniqueness guarantee - status endpoint + polling UI +
kindbranching - the eight acceptance tests, automated where the stack allows
- a note to the human: "register
PUBLIC_WEBHOOK_URLas the project's webhookUrl with the HivePay operator"
H Paydocs