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