UNPKG

@gpmpay/sdk

Version:

Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.

435 lines (329 loc) • 16.3 kB
# @gpmpay/sdk — instructions for AI coding agents > Canonical raw URL: <https://cdn.jsdelivr.net/npm/@gpmpay/sdk/AGENTS.md> > Rendered: <https://unpkg.com/browse/@gpmpay/sdk/AGENTS.md> > This file also ships inside the package at `node_modules/@gpmpay/sdk/AGENTS.md`. You are integrating **GPM Pay** (Vietnamese bank-transfer reconciliation) into a Node.js/TypeScript project using the official SDK. Read this file before writing any GPM Pay code. It is written to prevent the specific mistakes that produce integrations which *look* correct, pass review, and then silently fail to reconcile real money. --- ## 0. Non-negotiables 1. **Never hardcode the API token.** Read it from `process.env.GPMPAY_API_TOKEN`. Ask the human for the value; never invent one. 2. **Never send the token to a browser.** No `NEXT_PUBLIC_` prefix, no client component, no mobile app. It is a server-side secret with full account access. If the user asks for browser-side usage, refuse and explain: build the QR on the server, send only the QR payload/image down to the client. 3. **Never `JSON.stringify(req.body)` when verifying a webhook.** You must have the raw bytes. --- ## 1. How payment actually works here There is no payment gateway holding funds. The customer makes an ordinary bank transfer into the merchant's account. GPM Pay watches that account and POSTs a webhook for **every** incoming transaction, carrying the amount and the transfer content. That is the whole product. **There is exactly one integration model, and reconciliation is the merchant's job:** 1. You mint a payment code (`ORD123`, `DH4567`, whatever your system uses). 2. You build a VietQR carrying that code as the transfer content — locally, with no API call. 3. The payer transfers. GPM Pay POSTs you the transaction. 4. You find your code in `payload.content`, compare the amount yourself, and fulfil. > **If you have older GPM Pay knowledge, discard it.** There is no > `client.orders`, no `orders.create()`, no server-minted `referenceCode`, no > `waitForPayment()`, no `payload.order`, and no `orders:read`/`orders:write` > scopes. The merchant-order layer was removed from the platform. Writing code > against it produces 404s and 403s. --- ## 2. Setup ```bash pnpm add @gpmpay/sdk # npm i / yarn add also fine ``` ```ts import { GpmPay } from '@gpmpay/sdk'; const client = new GpmPay({ apiToken: process.env.GPMPAY_API_TOKEN! }); ``` The constructor throws `GpmPayConfigError` immediately if the token is missing or malformed. Do not wrap it in a try/catch that swallows the error — a missing token must crash at boot, not at checkout. `GpmPay.fromEnv()` reads `GPMPAY_API_TOKEN`. The API base URL defaults to production; do not set or document it. Environment variables to add to `.env.example`: ```bash GPMPAY_API_TOKEN= # required — from https://app.gpmpay.com/api-tokens GPMPAY_WEBHOOK_SECRET= # required — you will be receiving webhooks ``` Tell the human exactly which scopes to tick when creating the token. There are only three: | Scope | Needed for | |---|---| | `webhooks:manage` | registering the endpoint from code (`webhookSettings.*`, `webhookHistories.*`) | | `bank-accounts:read` | fetching the bank account and its BIN (`bankAccounts.*`), and the bank catalogue (`banks.list`) | | `transactions:read` | listing transactions (`transactions.*`) and `simulator.createTransaction` | A missing scope surfaces as `GpmPayPermissionError` with `.missingScope`. **The backend guard is fail-closed.** Any endpoint that declares no scope rejects *every* API token with a 403 — this is why the SDK exposes only `client.apiTokens.remove()`. If you find yourself reaching for a management endpoint (creating tokens, editing bank accounts, billing), it is dashboard-only; direct the human to <https://app.gpmpay.com>. That case surfaces as `GpmPayPermissionError` with `.reason === 'endpoint'`. --- ## 3. Build the QR with your own code ```ts import { buildPaymentInstructions } from '@gpmpay/sdk/vietqr'; const account = await client.bankAccounts.retrieve(bankAccountId); // or: (await client.bankAccounts.list({ status: 'ACTIVE' })).data[0]! const code = `DH${localOrder.id}`; // your code — see the rules below const info = buildPaymentInstructions({ bankAccount: account, // must carry its `bank` relation amount: Math.round(localOrder.total), // integer VND transferContent: code, }); // → { qrPayload, qrImageUrl, amount, transferContent, bankName, bankBin, // accountNumber, accountName } ``` This is pure local computation — no network call, so it cannot fail on a GPM Pay outage. If you already hold the BIN and account number, `buildVietQrPayload()` and `buildVietQrImageUrl()` take them directly. Store `code` on your local order. Show the QR, and show `code` as the transfer content the payer must include. **Rules for the code — these decide whether reconciliation works at all:** - **Keep it short and `A-Z0-9`.** VietQR truncates the description to 25 characters, and banks strip or fold diacritics and punctuation. - **Put it at the front** of the transfer content. Some banks prepend their own prefix (`CT DEN:...`), so trailing content is what gets cut. - **Make it unique per payment** and store it. It is the only thing tying a bank transfer back to an order. - **Derive it deterministically** from the cart/order id, so a double-submitted checkout produces the same code rather than a second pending payment. Nothing server-side deduplicates for you. --- ## 4. Match on the webhook ```ts onEvent: async (event) => { const { id: transactionId, content, transferAmount, transferType } = event.payload; if (transferType !== 'in') return; // A regex, NOT `content === code` — banks normalize the content. const code = /DH(\d+)/.exec(content)?.[0]; if (!code) return; const order = await db.orders.findByCode(code); if (!order) return; // You own this check — GPM Pay is not comparing amounts for you. if (order.total !== transferAmount) { await flagUnderpayment(order, transferAmount); return; } await fulfil(order, transactionId); } ``` Three things you must implement yourself, because nothing else does: **the code match**, **the amount check**, and **an expiry rule** for stale unpaid orders. --- ## 5. Receiving webhooks Register the endpoint once — this returns the signing secret exactly once: ```ts const { secret } = await client.webhookSettings.createHmacEndpoint({ url: 'https://shop.example.com/webhooks/gpmpay', }); // tell the human to store `secret` as GPMPAY_WEBHOOK_SECRET ``` **The header you verify depends on `authorizationType`.** `createHmacEndpoint` gives you `HMAC`, which is what you want. Do not write code that looks for `X-GPMPay-Signature` if the endpoint was configured differently: | `authorizationType` | Header sent | Verify with | |---|---|---| | `HMAC` (default) | `X-GPMPay-Signature: t=…,v1=…` | `constructWebhookEvent()` | | `API_KEY` | header from `authorizationHeaderName`, default `Authorization`; value is the **raw secret, no `Bearer` prefix** | `verifyApiKeyHeader()` | | `NONE` | nothing | — | There is no `X-GPMPay-Timestamp` header; the timestamp is the `t=` component inside the signature. `X-GPMPay-Event` is only sent by the WordPress driver. Check an existing endpoint with `npx gpmpay webhook settings`. **Express** — the raw body parser is mandatory: ```ts import express from 'express'; import { gpmpayWebhook } from '@gpmpay/sdk/webhooks'; app.post( '/webhooks/gpmpay', express.raw({ type: 'application/json' }), // ← required, do not omit gpmpayWebhook({ secret: process.env.GPMPAY_WEBHOOK_SECRET!, onEvent: async (event) => { /* §4 */ }, }), ); ``` If the app already has a global `express.json()`, capture the raw body instead of removing it: ```ts app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } })); ``` **Next.js App Router**: ```ts // app/api/webhooks/gpmpay/route.ts import { createNextWebhookHandler } from '@gpmpay/sdk/webhooks'; export const runtime = 'nodejs'; // verification uses node:crypto export const POST = createNextWebhookHandler({ secret: process.env.GPMPAY_WEBHOOK_SECRET!, onEvent: async (event) => { /* ... */ }, }); ``` **Next.js Pages Router** — disable the body parser with `export const config = { api: { bodyParser: false } }`, then use `readRawBody(req)` + `constructWebhookEvent(...)`. **Any other framework** — get the raw body yourself, then: ```ts import { constructWebhookEvent } from '@gpmpay/sdk/webhooks'; const event = constructWebhookEvent({ rawBody, // string | Buffer of the EXACT bytes signature: headers['x-gpmpay-signature'], secret: process.env.GPMPAY_WEBHOOK_SECRET!, }); ``` ### Payload — all 11 fields ```ts { id: string; // transaction id — YOUR IDEMPOTENCY KEY gateway: string; // bank code, e.g. 'MB' transactionDate: string; // ISO accountNumber: string; subAccount: string | null; // always null today content: string; // transfer content, truncated to 100 chars ← YOU MATCH ON THIS transferType: 'in' | 'out'; transferAmount: number; // already a number, not a string accumulated: number | null; // running balance, when the bank reports it referenceCode: string; // the BANK's transfer id — NOT your code source: 'REAL' | 'SIMULATED'; } ``` **`content` vs `referenceCode` is the trap.** Your payment code lives in `content`. `referenceCode` is the bank's own transaction reference and has nothing to do with your order — matching on it will never work. ### Two properties your handler MUST have 1. **Idempotent on `event.payload.id`** (the transaction id). GPM Pay retries on `10s, 30s, 2m, 10m, 1h, 6h` — up to 6 attempts. The same transaction will arrive more than once whenever your first response is slow or fails. Record processed ids and short-circuit. 2. **Responds within 5 seconds.** The delivery is aborted after that and retried. `gpmpayWebhook` already answers `200` before awaiting `onEvent` (`respondEarly: true`); if you write a handler by hand, acknowledge first and do the work afterwards (or enqueue it). ### No public URL? There is nothing to poll on the GPM Pay side — payment state lives in your database. Run `client.transactions.list()` on a schedule and apply the same matching logic from §4. --- ## 6. Data-shape rules the type system cannot enforce | Rule | Consequence if ignored | |---|---| | Money is a **string** when read (`"50000"`), an **integer** when written | `tx.amount + 1000` produces `"500001000"` | | Use `toVnd(x)` for arithmetic, `formatVnd()` for display | — | | Webhook `transferAmount` is already a `number` | — | | Timestamps are ISO strings; writes accept `string \| Date` | — | | `limit` is capped at 50 server-side | the SDK clamps and warns once | | Walk large result sets with `transactions.listAll()` | hand-rolled paging drifts as rows are inserted | | Enums are string-literal unions, not TS `enum`s | — | --- ## 7. Errors Catch specific classes, not strings: ```ts import { GpmPayPermissionError, // 403 — .reason: 'scope' | 'endpoint' | 'ownership' GpmPayAuthenticationError, // 401 — .reason: token_expired | token_inactive | invalid_token | ... GpmPayBadRequestError, // 400 — .validationMessages: string[] GpmPayNotFoundError, // 404 — .resource GpmPayRateLimitError, // 429 — .retryAfterSeconds GpmPayServerError, // 5xx GpmPayError, } from '@gpmpay/sdk'; ``` `GpmPayPermissionError.reason` tells you which 403 you hit: - `'scope'` — the token is valid but lacks a scope; `.missingScope` names it. - `'endpoint'` — the route accepts no API token at all. Do not retry with more scopes; it is dashboard-only. - `'ownership'` — the resource belongs to a different account. Also: - Every API error carries `.requestId` — log it; support uses it to find the server-side trace. - The SDK already retries idempotent requests and 429s with backoff. Do not add your own retry loop on top. - Prefer `GpmPayError.isGpmPayError(e)` over `instanceof` if the project might end up with both a CJS and an ESM copy loaded. --- ## 8. Testing the integration ```ts const client = new GpmPay({ apiToken, sandbox: true }); ``` Simulate a transfer carrying your own code: ```ts // Returns `{ transaction, historyIds }` — an envelope, NOT a bare Transaction. // `result.id` is undefined; read `result.transaction.id`. const { transaction, historyIds } = await client.simulator.createTransaction({ bankAccountId, amount: 50_000, transferContent: 'DH123', // your code — the webhook fires with this content }); ``` `simulator` refuses to run against production unless passed `{ allowOnProduction: true }`, and webhooks only fire for endpoints with `fireOnSimulated` enabled. In unit tests, inject `fetch` rather than hitting the network: ```ts const client = new GpmPay({ apiToken: 'gpm_TESTpub1_abcdefghijklmnopqrstuvwx', fetch: mockFetch }); ``` Verify a webhook handler by signing a body yourself: ```ts import { signWebhookPayload } from '@gpmpay/sdk/webhooks'; const signature = signWebhookPayload({ rawBody, secret }); ``` CLI smoke tests. Run these before claiming the integration works: ```bash npx gpmpay ping # token valid? which scopes does it REALLY have? npx gpmpay accounts list # the only way to obtain a bankAccountId # Fire a signed event at the human's handler. Needs no API token and makes no # GPM Pay API call, so it works before an account even exists. npx gpmpay webhook send --url http://localhost:3000/webhooks/gpmpay \ --secret $GPMPAY_WEBHOOK_SECRET # The handler MUST reject these two. If either returns 200, verification is # not actually wired up and you must fix it before reporting success. npx gpmpay webhook send --url http://localhost:3000/webhooks/gpmpay \ --secret $GPMPAY_WEBHOOK_SECRET --bad-signature npx gpmpay webhook send --url http://localhost:3000/webhooks/gpmpay \ --secret $GPMPAY_WEBHOOK_SECRET --skew 600 # Also try Vietnamese diacritics — this is where hand-rolled verification breaks: npx gpmpay webhook send --url http://localhost:3000/webhooks/gpmpay \ --secret $GPMPAY_WEBHOOK_SECRET --content "chuyển tiền có dấu" ``` `npx gpmpay ping` probes each scope individually, so an under-scoped token shows up as a `Missing` line rather than as a mysterious 403 later. Once real deliveries are flowing, `npx gpmpay webhook history --status FAILED` shows the status code the endpoint returned and the attempt count. --- ## 9. Checklist before you say you are done - [ ] Token read from env; nothing hardcoded; nothing `NEXT_PUBLIC_` - [ ] No reference to `client.orders`, `waitForPayment`, `payload.order`, `payload.code`, or an `orders:*` scope anywhere in the code - [ ] Amounts are integer VND (`Math.round()` at the boundary) - [ ] The payment code is short, `A-Z0-9`, at the front of the transfer content, stored locally, and derived deterministically from the order - [ ] The QR is shown **and** the transfer content is shown as copyable text - [ ] Webhook route mounted with a raw body parser - [ ] Webhook handler is idempotent on `payload.id` and responds fast - [ ] Handler guards on `transferType === 'in'` - [ ] Handler matches `payload.content` with a regex, not `===` - [ ] Handler compares the amount against the local order - [ ] There is a rule for expiring stale unpaid orders - [ ] `GPMPAY_WEBHOOK_SECRET` stored after `createHmacEndpoint` - [ ] Errors caught by class; `requestId` logged - [ ] `.env.example` updated - [ ] `gpmpay webhook send --bad-signature` and `--skew 600` both get a 401 from the handler — verified by running them, not by reading the code --- ## 10. Where to look next - Full API surface, options, and gotchas: `README.md` in this package - Deep guides: `docs/en/` (English) and `docs/vi/` (Vietnamese) — the payment model is covered in `02-payments.md` - Runnable examples: `examples/` - Types are shipped — read the `.d.ts` or let the editor autocomplete rather than guessing field names.