UNPKG

@gpmpay/sdk

Version:

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

426 lines (307 loc) • 17.4 kB
# @gpmpay/sdk Official Node.js SDK for [GPM Pay](https://gpmpay.com) — build VietQR codes, receive transaction webhooks, reconcile payments yourself. **Zero dependencies** · Node >= 18.17 · TypeScript included · ESM + CommonJS · MIT 🇻🇳 [Tiếng Việt](https://www.npmjs.com/package/@gpmpay/sdk) · 📚 [Online docs](https://app.gpmpay.com/docs#nodejs-sdk) > **The in-depth guides ship inside the package.** Once installed, open > `node_modules/@gpmpay/sdk/docs/en/` (5 guides) · `AGENTS.md` (instructions for AI agents) · `examples/` (runnable examples). > Not installed yet? Read them on the web: **[browse every file in the package](https://unpkg.com/browse/@gpmpay/sdk/)** — or see the "Documentation" table at the bottom of this page. > ⚠️ **Server-side only.** The API token is a secret. Shipping it to a browser or a mobile app hands your GPM Pay account to anyone who can read the source. --- ## Install ```bash pnpm add @gpmpay/sdk # or: npm i @gpmpay/sdk / yarn add @gpmpay/sdk ``` ## What GPM Pay does No gateway holds the money. The customer makes an ordinary bank transfer into your account; GPM Pay watches that account and POSTs a webhook for **every** incoming transaction, carrying the amount and the transfer content. **Reconciliation is yours.** You mint your own order code, put it in the transfer content, and match on it in `payload.content` when the webhook arrives. GPM Pay mints no codes, stores no orders, and matches nothing on your behalf — it is a transaction feed. The whole model, with the reconciliation pitfalls that bite in production: [`docs/en/02-payments.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/en/02-payments.md). ## 60-second start **1.** Create an API token at <https://app.gpmpay.com/api-tokens> with the `webhooks:manage` and `bank-accounts:read` scopes. **2.** Install and check the token: ```bash export GPMPAY_API_TOKEN=gpm_xxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyy pnpm add @gpmpay/sdk npx gpmpay ping ``` ``` ✓ Connected to https://api.gpmpay.com/api/v1 (182 ms) Token gpm_a1b2c3d4•••••••• Scopes bank-accounts:read, webhooks:manage Missing transactions:read ``` **3.** Build the QR with your own code: ```ts import { GpmPay } from '@gpmpay/sdk'; import { buildPaymentInstructions } from '@gpmpay/sdk/vietqr'; const client = new GpmPay({ apiToken: process.env.GPMPAY_API_TOKEN! }); const account = (await client.bankAccounts.list({ status: 'ACTIVE' })).data[0]!; const code = `ORD${localOrder.id}`; // your code — the payer must type this const { qrImageUrl, transferContent } = buildPaymentInstructions({ bankAccount: account, amount: Math.round(localOrder.total), // VND, integer transferContent: code, }); ``` **4.** Match it when the webhook arrives: ```ts onEvent: async (event) => { const code = /ORD(\d+)/.exec(event.payload.content)?.[0]; const order = code && await db.orders.findByCode(code); if (order && order.total === event.payload.transferAmount) { await fulfil(order); } } ``` The full webhook setup is [below](#webhooks). --- ## Configuration ```ts const client = new GpmPay({ apiToken: process.env.GPMPAY_API_TOKEN!, // REQUIRED sandbox: false, // true → sandbox environment timeoutMs: 30_000, maxRetries: 2, userAgent: 'my-shop/2.1', defaultHeaders: { 'X-Trace-Id': traceId }, // cannot override Authorization onRequest: (e) => logger.debug(e), // never receives the token onResponse: (e) => metrics.timing(e.durationMs), }); const client = GpmPay.fromEnv(); // reads GPMPAY_API_TOKEN ``` The SDK **refuses to construct without a token** — a misconfiguration surfaces at deploy time, not when your first customer hits checkout: ```ts new GpmPay({ apiToken: 'sk_live_x' }); // throws GpmPayConfigError — 'invalid_api_token_format' ``` The SDK **never prints the token**. `client.toString()` and `console.log(client)` show only the public prefix (`gpm_a1b2c3d4••••••••`), which is safe to log and to paste into a support ticket. ### Scopes The backend has exactly **three** scopes: | Scope | Methods it unlocks | |---|---| | `webhooks:manage` | `webhookSettings.*`, `webhookHistories.*` | | `bank-accounts:read` | `bankAccounts.list`, `bankAccounts.retrieve`, `banks.list` | | `transactions:read` | `transactions.list`, `transactions.listAll`, `transactions.retrieve`, `simulator.createTransaction` | A token missing a scope gets a `GpmPayPermissionError` whose `.missingScope` names it. > ⚠️ **The backend's `ApiTokenGuard` is fail-closed.** Any endpoint that declares no scope rejects **every** API token with a 403, regardless of ownership. The SDK therefore models only the routes an API token can actually reach — e.g. `client.apiTokens` exposes just `remove()`, because the remaining token-management routes are dashboard-only. That case surfaces as `GpmPayPermissionError` with `.reason === 'endpoint'`. --- ## Webhooks ### Verifying the signature Which header arrives **depends on the `authorizationType`** you set on the webhook setting — the three modes use three completely different headers: | `authorizationType` | Header GPM Pay sends | How to check it | |---|---|---| | `HMAC` *(default)* | `X-GPMPay-Signature: t=<unix>,v1=<hex>` — renameable via `authorizationHeaderName` | `constructWebhookEvent()` | | `API_KEY` | A header you choose, **defaulting to `Authorization`**; the value is the **raw secret, with no `Bearer` prefix** | `verifyApiKeyHeader(received, expected)` | | `NONE` | **No authentication header at all** | Unverifiable — internal endpoints only | Three things people get wrong: - **There is no `X-GPMPay-Timestamp` header.** The timestamp is the `t=` part inside the signature value. - **The `HTTP` driver does not send `X-GPMPay-Event`** — only the WordPress driver does. `event.type` is an SDK-side default. - The enum is `HMAC`, **not** `HMAC_SHA256`. The algorithm is SHA-256; the enum name is not. For `HMAC`: the signature covers the string `` `${t}.${rawBody}` ``, with a ±300 second skew window. ```ts import { constructWebhookEvent } from '@gpmpay/sdk/webhooks'; const event = constructWebhookEvent({ rawBody, // RAW BYTES, not a parsed object signature: headers['x-gpmpay-signature'], secret: process.env.GPMPAY_WEBHOOK_SECRET!, }); if (event.payload.transferType === 'in') { await reconcile(event.payload); } ``` For `API_KEY`: ```ts import { verifyApiKeyHeader } from '@gpmpay/sdk/webhooks'; if (!verifyApiKeyHeader(headers.authorization, process.env.GPMPAY_WEBHOOK_SECRET!)) { return res.status(401).end(); } ``` > ⚠️ **You must use the raw body.** `JSON.stringify(req.body)` changes key order and whitespace, so the signature will **always** fail. The SDK detects this and says so instead of leaving you to guess. ### Express ```ts import express from 'express'; import { gpmpayWebhook } from '@gpmpay/sdk/webhooks'; app.post( '/webhooks/gpmpay', express.raw({ type: 'application/json' }), // ← required gpmpayWebhook({ secret: process.env.GPMPAY_WEBHOOK_SECRET!, onEvent: async (event) => { const code = /ORD(\d+)/.exec(event.payload.content)?.[0]; if (code) await reconcile(code, event.payload.transferAmount); }, }), ); ``` If `express.json()` already runs globally, capture the raw body with the `verify` hook: ```ts app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } })); ``` **Next.js (App + Pages Router), Fastify, Hono / Cloudflare Workers / Deno, and any other framework:** [`docs/en/03-webhooks.md` §4](https://unpkg.com/browse/@gpmpay/sdk/docs/en/03-webhooks.md). ### Registering an endpoint and getting the secret ```ts const { setting, secret } = await client.webhookSettings.createHmacEndpoint({ url: 'https://shop.example.com/webhooks/gpmpay', }); console.log(secret); // ← shown ONCE, store it as GPMPAY_WEBHOOK_SECRET now ``` ### Retries and idempotency GPM Pay aborts a delivery after **5 seconds** and retries on the schedule `10s → 30s → 2m → 10m → 1h → 6h`, up to 6 attempts. - Return `200` **fast** and do the work afterwards (`gpmpayWebhook` does this by default — `respondEarly: true`). - Your endpoint **must be idempotent on `payload.id`** — the same transaction can arrive more than once. ```ts import { WEBHOOK_RETRY_SCHEDULE_SECONDS, WEBHOOK_MAX_ATTEMPTS } from '@gpmpay/sdk/webhooks'; ``` ### Payload ```ts event.payload.id // transaction id — USE AS YOUR IDEMPOTENCY KEY event.payload.content // transfer content, truncated to 100 chars — YOUR CODE IS IN HERE event.payload.transferAmount // number, not string event.payload.referenceCode // the BANK's transfer id — not your order code ``` All 11 fields, plus the `content` vs `referenceCode` trap: [`docs/en/03-webhooks.md` §2](https://unpkg.com/browse/@gpmpay/sdk/docs/en/03-webhooks.md). --- ## VietQR This whole section is **pure client-side, no network calls** — the backend has no VietQR endpoint. ```ts import { buildPaymentInstructions, buildVietQrPayload, buildVietQrImageUrl, } from '@gpmpay/sdk/vietqr'; const account = await client.bankAccounts.retrieve(bankAccountId); // The short path: one call gives a checkout page everything it needs. const info = buildPaymentInstructions({ bankAccount: account, // must carry its `bank` relation (the BIN lives there) amount: 250_000, transferContent: 'ORD1042', // YOUR code — you mint it, you match it }); // → { qrPayload, qrImageUrl, amount, transferContent, bankName, bankBin, accountNumber, accountName } // Or build the pieces directly if you already hold the BIN and account number: buildVietQrPayload({ bankBin: '970422', accountNumber: '1234567890', amount: 250_000, description: 'ORD1042' }); buildVietQrImageUrl({ bankBin: '970422', accountNumber: '1234567890', amount: 250_000, description: 'ORD1042' }); ``` > ⚠️ **The reconciliation code is yours.** GPM Pay mints nothing. Choose a short, unaccented code and put it **at the front** of the transfer content — VietQR truncates the description to 25 characters, and some banks prepend their own prefix to `content`. Match with a regex rather than `===`. --- ## CLI ``` gpmpay ping Check the token and probe each scope for real gpmpay accounts list List bank accounts — where --account comes from gpmpay accounts get <id> gpmpay transactions list [--limit <n>] [--account <uuid>] [--type IN|OUT] gpmpay simulate tx --account <uuid> --amount <vnd> --content <text> gpmpay webhook send --url <url> Sign a sample payload and POST it to your handler gpmpay webhook listen [--port 4444] [--secret <s>] gpmpay webhook verify --signature "t=..,v1=.." [--file body.json] gpmpay webhook settings Registered endpoints and each one's auth mode gpmpay webhook history Delivery history, status codes, attempt counts gpmpay webhook retry <id> Re-queue one failed delivery --token --sandbox --json --no-color -h -v ``` Exit codes: `0` OK · `1` general error · `2` usage / missing token · `3` authentication failed (401) · `4` network/timeout. `--json` redacts every secret field. ### Test the flow without real money ```bash npx gpmpay accounts list # copy a uuid npx gpmpay webhook listen --port 4444 --secret $GPMPAY_WEBHOOK_SECRET # another terminal — POST a signed event to your handler. # No API token needed, no GPM Pay API call: npx gpmpay webhook send --url http://localhost:4444 --secret $GPMPAY_WEBHOOK_SECRET # your handler must REJECT both of these: npx gpmpay webhook send --url http://localhost:4444 --secret $GPMPAY_WEBHOOK_SECRET --bad-signature npx gpmpay webhook send --url http://localhost:4444 --secret $GPMPAY_WEBHOOK_SECRET --skew 600 ``` To make GPM Pay itself fire a real webhook (rather than the CLI faking one), use `simulate` on the sandbox: ```bash npx gpmpay simulate tx --sandbox --account <uuid> --amount 50000 --content ORD1042 npx gpmpay webhook history --sandbox # did it deliver, and with what status ``` > `simulate` refuses to run against production unless you pass `--allow-production`. Webhooks only fire for endpoints with `fireOnSimulated` enabled — check with `gpmpay webhook settings`. --- ## Error handling ``` GpmPayError ├── GpmPayConfigError local failure, nothing sent ├── GpmPayConnectionError DNS/TCP/TLS (.syscallCode) ├── GpmPayTimeoutError (.timeoutMs) ├── GpmPayWebhookSignatureError (.reason) └── GpmPayAPIError (.status, .requestId, .rawBody) ├── GpmPayBadRequestError 400 (.validationMessages) ├── GpmPayAuthenticationError 401 (.reason) ├── GpmPayPermissionError 403 (.missingScope, .reason) ├── GpmPayNotFoundError 404 (.resource) ├── GpmPayRateLimitError 429 (.retryAfterSeconds) └── GpmPayServerError 5xx ``` `GpmPayPermissionError.reason` separates the three kinds of 403: | `.reason` | Meaning | |---|---| | `'scope'` | Valid token, missing scope — see `.missingScope` | | `'endpoint'` | Dashboard-only route; **no** scope unlocks it | | `'ownership'` | The resource exists but belongs to another account | ```ts try { await client.webhookSettings.create({ ... }); } catch (error) { if (error instanceof GpmPayPermissionError) { console.error('403:', error.reason, error.missingScope); } } ``` Every `GpmPayAPIError` carries `.requestId` — quote it in a support ticket to find the server log. If CJS and ESM copies end up in one process, `instanceof` can lie; use `GpmPayError.isGpmPayError(error)`. --- ## Data types — 3 things to remember | | Reading | Writing | |---|---|---| | **Money** | `string` (`"50000"`, from Prisma `Decimal`) | integer `number` (`50000`) | | **Time** | ISO `string` | `string \| Date` | | **Enums** | string-literal unions, not TS `enum`s | | ```ts import { toVnd, formatVnd } from '@gpmpay/sdk'; toVnd(transaction.amount); // 50000 formatVnd(transaction.amount); // '50.000 ₫' ``` **Pagination:** the API caps `limit` at 50; the SDK clamps and warns once. To walk everything, use `transactions.listAll()` — it pages for you. --- ## Sandbox & testing ```ts const client = new GpmPay({ apiToken, sandbox: true }); // Returns an envelope, not a bare Transaction. const { transaction, historyIds } = await client.simulator.createTransaction({ bankAccountId, amount: 50_000, transferContent: 'ORD1042', // the exact code you will reconcile on }); // An empty historyIds means no endpoint has fireOnSimulated on — your handler // will never be called. console.log(transaction.id, historyIds.length); ``` `simulator` refuses to run against production unless you pass `{ allowOnProduction: true }`. In unit tests, inject `fetch` (`new GpmPay({ apiToken, fetch: myMockFetch })`) rather than hitting the network — details in [`docs/en/04-errors-and-testing.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/en/04-errors-and-testing.md). --- ## Coming from raw `fetch` | Before | After | |---|---| | `JSON.parse(res).data.data` + `.meta` | `client.transactions.list()` → `{ data, meta }` | | `if (res.status === 403) { ... }` | `catch (e) { if (e instanceof GpmPayPermissionError) ... }` | | Hand-rolled HMAC verification | `constructWebhookEvent()` | | Hand-rolled EMVCo + CRC16 | `buildVietQrPayload()` | | `Number(tx.amount)` scattered around | `toVnd(tx.amount)` | --- ## Documentation Every file below ships with the package (already in `node_modules/@gpmpay/sdk/`) and is readable on the web without installing anything: | Document | Contents | |---|---| | [`docs/en/01-getting-started.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/en/01-getting-started.md) | From zero to your first payment | | [`docs/en/02-payments.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/en/02-payments.md) | The self-reconciliation model, VietQR, matching pitfalls | | [`docs/en/03-webhooks.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/en/03-webhooks.md) | Registration, 3 auth modes, Express/Next/Fastify/Hono, retries, debugging | | [`docs/en/04-errors-and-testing.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/en/04-errors-and-testing.md) | Error tree, retries, sandbox, unit tests | | [`docs/en/05-api-reference.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/en/05-api-reference.md) | Every option, method and type | | [`AGENTS.md`](https://unpkg.com/browse/@gpmpay/sdk/AGENTS.md) | Instructions for AI coding agents | | [`examples/`](https://unpkg.com/browse/@gpmpay/sdk/examples/) | Runnable examples | | [`docs/vi/`](https://unpkg.com/browse/@gpmpay/sdk/docs/vi/) | All of the above, in Vietnamese | Browse every file in the package: <https://unpkg.com/browse/@gpmpay/sdk/> · Docs site: <https://app.gpmpay.com/docs#nodejs-sdk>. For raw markdown (AI agents, `curl`, scripts) swap `unpkg.com/browse/` for `cdn.jsdelivr.net/npm/`. ## Compatibility - Node >= 18.17 (needs `fetch`, `AbortSignal.timeout`, `node:util.parseArgs`) - Works from both ESM and CommonJS - TypeScript: declarations included, no `@types/*` needed - **No** browser support — the API token is a server-side secret ## License MIT © GPM Softwares