# 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:///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 ) { 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 : 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=&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 `, 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` ```ts { 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): ```ts { 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; // echoed in webhook returnUrl?: string; cancelUrl?: string; // absolute URLs; hosted-page return targets } ``` Response `data` (the `Payment` object; same shape from GET): ```ts { 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=&status=&outcome=`. `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: X-HivePay-Signature: t=,v1= ``` Body: ```ts { 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=,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 signed like a webhook (same verification, primary secret), X-HivePay-Event: user.lookup body { event: "user.lookup", project, query: , queryType: "email"|"id"|"username", externalUserId: , createdAt } MUST match `query` against username, user id AND email (emails arrive lower-cased) reply { found: true, externalUserId: , 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` = `:` 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 /api/dev/dinger/simulate` `{ "reference": "", "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//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 `/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"