# @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
