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