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 in API.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)

  1. 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).
  2. A webhook endpoint at PUBLIC_WEBHOOK_URL that verifies the signature, acknowledges fast, and credits the user once per reference.
  3. A status endpoint the UI polls, proxying GET /api/v1/payments/:reference.
  4. UI that branches on kind (QR image / redirect / manual instructions) and polls.
  5. A persisted credited_references set (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 → render qrCode string as a QR image; poll.
  • REDIRECT | INVOICE → top-level navigate to checkoutUrl; on return, poll.
  • MANUAL → show manual.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)

  1. API key only on the server. Webhook secret only on the server.
  2. Dedupe crediting on data.reference, never on id or the delivery header.
  3. Credit only on event === "payment.paid"; the URL, a poll, or payment.proof_submitted never credit.
  4. idempotencyKey = <userId>:<checkoutAttemptId> where the attempt id is minted once client-side per attempt and reused on retries. No timestamps.
  5. Read requiresBilling from /payment-methods; send billing for those channels. 5b. Order state is derived from references: an order is paid once any of its references is PAID, and stays paid.
  6. Read the raw body for signature verification; frameworks that pre-parse JSON must be configured to expose raw bytes for this route.
  7. qrCode/checkoutUrl exist only while PENDING — don't cache the Payment object for rendering.
  8. Store reference on the order; put your order id in metadata.

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.

  1. Create (KBZPay, packageId) → 201, kind: "QR", qrCode non-null. Simulate SUCCESS → webhook payment.paid received, signature valid → user balance += creditAmount, exactly once.
  2. POST /redeliver on that reference → second payment.paid → balance unchanged.
  3. Create → simulate FAIL → payment.failed, order marked failed, balance unchanged. Then simulate SUCCESS → payment.paid, credited.
  4. Same idempotencyKey twice → same reference, second response 200 with Idempotent-Replayed: true.
  5. 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.
  6. Create with gateway: "MANUAL" → kind: "MANUAL", manual.account non-null. POST /proof → 201 and payment.proof_submitted received.
  7. Create with a card provider and no billing → 400 surfaced as a readable message; with billing → kind: "REDIRECT", checkoutUrl non-null.
  8. Your webhook endpoint responds in < 10 s with the database made slow or unavailable (ack before work).

Acceptance tests — Path A

  1. Mint a session → 201, url starts with <HIVEPAY_URL>/topup/cs_. Same idempotencyKey again → 200 + Idempotent-Replayed: true, same id.
  2. Open url in 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.
  3. POST /api/dev/dinger/simulate with the session's latest payment.reference and SUCCESS → your webhook receives payment.paid → user credited once; the page shows "Payment confirmed" and returns to returnUrl?ref=…&status=PAID.
  4. 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 packageId or the server decides amountMMK)
  • webhook endpoint at PUBLIC_WEBHOOK_URL implementing the handler algorithm
  • credited_references persistence with a uniqueness guarantee
  • status endpoint + polling UI + kind branching
  • the eight acceptance tests, automated where the stack allows
  • a note to the human: "register PUBLIC_WEBHOOK_URL as the project's webhookUrl with the HivePay operator"