@gpmpay/sdk
Version:
Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.
426 lines (307 loc) • 17.4 kB
Markdown
# @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