@gpmpay/sdk
Version:
Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.
358 lines (281 loc) • 12.6 kB
Markdown
# API reference
[Tiếng Việt](../vi/05-api-reference.md) · [Index](./README.md)
Every method returns a `Promise` and accepts a trailing
`options?: { signal?, timeoutMs?, headers? }`.
---
## `new GpmPay(options)`
| Option | Type | Default | Notes |
|---|---|---|---|
| `apiToken` | `string` | — | **Required.** Missing or malformed throws `GpmPayConfigError` immediately |
| `baseUrl` | `string` | `https://api.gpmpay.com` | Rarely needs setting. Accepts a bare host, `host/api`, or `host/api/v1` |
| `sandbox` | `boolean` | `false` | `true` → the test environment. Ignored when `baseUrl` is set |
| `timeoutMs` | `number` | `30000` | Per-request timeout |
| `maxRetries` | `number` | `2` | `0` disables |
| `retryBaseDelayMs` | `number` | `500` | Full-jitter exponential, capped at 8s |
| `fetch` | `typeof fetch` | global | Inject for tests / proxies |
| `userAgent` | `string` | — | Appended to the SDK's UA |
| `defaultHeaders` | `Record<string,string>` | `{}` | Cannot override `Authorization` |
| `strictTokenFormat` | `boolean` | `true` | Skip the format check; presence is **still** required |
| `onRequest` | `(e) => void` | — | `{ method, url, requestId, attempt }` |
| `onResponse` | `(e) => void` | — | `{ requestId, status, durationMs, attempt }` |
### Statics & properties
```ts
GpmPay.fromEnv(overrides?) // reads GPMPAY_API_TOKEN
client.baseUrl // 'https://api.gpmpay.com/api/v1'
client.tokenPrefix // 'gpm_a1b2c3d4' — safe to log
client.toString() // 'GpmPay(https://…, gpm_a1b2c3d4••••••••)'
client.ping(options?) // PingResult
client.request(method, path, options?) // escape hatch for unmodelled endpoints
```
`ping()` issues one minimal read per scope, in parallel, and infers which scopes
the token really carries. It cannot read the token's own record: `GET /api-tokens`
declares no scope, so the fail-closed guard rejects every API token that asks.
A scope lands in `denied` only on a genuine 403 — a 401 or a 5xx propagates
instead of being misreported as a missing scope.
```ts
interface PingResult {
ok: true;
baseUrl: string;
tokenPrefix: string;
latencyMs: number;
scopes: { granted: ApiTokenScope[]; denied: ApiTokenScope[] };
}
```
---
## `client.transactions`
| Method | HTTP | Scope | Returns |
|---|---|---|---|
| `list(params?)` | `GET /transactions` | `transactions:read` | `Page<Transaction>` |
| `listAll(params?)` | — | `transactions:read` | `AsyncGenerator<Transaction>` — auto-paginates |
| `retrieve(id)` | `GET /transactions/:id` | `transactions:read` | `TransactionDetail` |
```ts
interface ListTransactionsParams extends ListParams {
bankAccountId?: string;
type?: 'IN' | 'OUT';
source?: 'REAL' | 'SIMULATED';
}
interface ListParams {
page?: number;
limit?: number; // capped at 50 server-side
sortBy?: string;
sortOrder?: 'asc' | 'desc';
search?: string; // matches referenceCode and transferContent
startDate?: string | Date; // transactions: filters on transactionTime
endDate?: string | Date;
filters?: Record<string, unknown>; // serialized to a JSON string
}
```
```ts
interface Page<T> {
data: T[];
meta: { page: number; limit: number; totalItems: number; totalPages: number };
}
```
---
## `client.bankAccounts`
| Method | HTTP | Scope | Returns |
|---|---|---|---|
| `bankAccounts.list(params?)` | `GET /bank-accounts` | `bank-accounts:read` | `Page<BankAccount>` |
| `bankAccounts.retrieve(id)` | `GET /bank-accounts/:id` | `bank-accounts:read` | `BankAccount` |
Both include the `bank` relation, which carries the NAPAS `bin` the VietQR
helpers need.
> Bank-account writes (`create`, `update`, `remove`, `rotate-ingest-secret`,
> `subscribe`) are **deliberately not exposed**: there is no
> `bank-accounts:write` scope, `rotate-ingest-secret` returns a plaintext
> secret, and `subscribe` **spends money**. Use the dashboard for those.
---
## `client.banks`
| Method | HTTP | Scope | Returns |
|---|---|---|---|
| `banks.list()` | `GET /banks` | `bank-accounts:read` | `Bank[]` — a flat array, not a page |
The catalogue of active banks. You only need it for a bank you have **no account
at** — a bank picker, or a VietQR for an external account. For your own accounts
the NAPAS `bin` already arrives on the `bank` relation of `bankAccounts.*`.
---
## `client.webhookSettings`
Scope: `webhooks:manage`.
| Method | HTTP |
|---|---|
| `list(params?)` | `GET /webhook-settings` |
| `retrieve(id)` | `GET /webhook-settings/:id` |
| `create(params)` | `POST /webhook-settings` |
| `update(id, params)` | `PATCH /webhook-settings/:id` |
| `remove(id)` | `DELETE /webhook-settings/:id` |
| `createHmacEndpoint(params)` | convenience — creates an HMAC endpoint and returns the secret once |
`CreateWebhookSettingParams` is a union discriminated on `driver`, so the
backend's conditional validation becomes a compile error instead of a 400:
```ts
{ driver?: 'HTTP'; url: string }
{ driver: 'WORDPRESS'; url: string; wpSecret: string } // wpSecret required
{ driver: 'GOOGLE_SHEETS'; url: string; googleSheetName?: string }
{ driver: 'TELEGRAM'; telegramChatId: string; ... } // chatId required
```
combined with `{ scope?: 'ALL' }` or
`{ scope: 'SPECIFIC'; bankAccountIds: [string, ...string[]] }`.
```ts
const { setting, secret } = await client.webhookSettings.createHmacEndpoint({
url: 'https://shop.example.com/webhooks/gpmpay',
name: 'Production',
scope: 'SPECIFIC',
bankAccountIds: [bankAccountId],
secret: undefined, // generates a 256-bit hex secret when omitted
});
```
---
## `client.webhookHistories`
Scope: `webhooks:manage`.
| Method | HTTP |
|---|---|
| `list(params?)` | `GET /webhook-histories` — filter by `settingId`, `transactionId`, `status` |
| `retrieve(id)` | `GET /webhook-histories/:id` |
| `retry(id)` | `POST /webhook-histories/:id/retry` |
---
## `client.apiTokens`
| Method | HTTP |
|---|---|
| `remove(id)` | `DELETE /api-tokens/:id` |
One method, on purpose. `DELETE /api-tokens/:id` is the only api-token route
that accepts an API token (it carries `@AllowAnyApiToken()`, so an integration
can revoke its own token on disconnect; ownership is still enforced server-side).
Listing, creating, regenerating and status changes are dashboard-session routes
— modelling them here would only ship methods that always 403.
Manage tokens at <https://app.gpmpay.com/api-tokens>.
---
## `client.simulator`
Development and testing only. Refuses to run against production unless passed
`{ allowOnProduction: true }`.
| Method | HTTP | Scope |
|---|---|---|
| `createTransaction(params, opts?)` | `POST /simulator/transactions` | `transactions:read` |
> `transactions:read` on a route that *creates* a transaction reads oddly. It is
> a deliberate backend trade-off to avoid minting a new scope: simulated rows
> always carry `source: 'SIMULATED'` and only fan out to endpoints with
> `fireOnSimulated` enabled.
```ts
interface SimulateTransactionParams {
bankAccountId: string;
amount: number; // VND, integer
transferContent: string; // <= 100 chars — put YOUR reconciliation code here
type?: 'IN' | 'OUT'; // default IN
referenceCode?: string; // <= 64 chars — the "bank's" transaction reference
counterAccount?: string;
counterName?: string;
transactionTime?: string | Date;
}
// The route returns an envelope, NOT a bare Transaction.
interface SimulatedTransactionResult {
transaction: Transaction;
historyIds: string[]; // one id per queued webhook delivery
}
```
```ts
const { transaction, historyIds } = await client.simulator.createTransaction({
bankAccountId,
amount: 50_000,
transferContent: 'DH1042',
});
// An empty historyIds means no endpoint has fireOnSimulated on, so your handler
// will never be called. This is the answer to "why didn't the webhook arrive?".
if (historyIds.length === 0) {
console.warn('No endpoint subscribed — enable fireOnSimulated in the dashboard.');
}
```
---
## `@gpmpay/sdk/webhooks`
```ts
verifyWebhookSignature(input): boolean
assertWebhookSignature(input): { timestamp } // throws GpmPayWebhookSignatureError
constructWebhookEvent(input): GpmPayWebhookEvent // verify + JSON.parse
signWebhookPayload({ rawBody, secret, timestamp? }): string
verifyApiKeyHeader(received, expected): boolean // for authorizationType: 'API_KEY'
gpmpayWebhook(options) // Express middleware
createNextWebhookHandler(options) // Next App Router POST handler
verifyNextRequest(request, options) // Next App Router, manual
readRawBody(stream, maxBytes?) // Next Pages Router
SIGNATURE_HEADER // 'X-GPMPay-Signature'
EVENT_HEADER // 'X-GPMPay-Event'
DEFAULT_TOLERANCE_SECONDS // 300
WEBHOOK_RETRY_SCHEDULE_SECONDS // [10, 30, 120, 600, 3600, 21600]
WEBHOOK_MAX_ATTEMPTS // 6
WEBHOOK_DELIVERY_TIMEOUT_MS // 5000
```
```ts
interface VerifyWebhookInput {
rawBody: string | Buffer | Uint8Array; // the EXACT bytes
signature: string; // 't=<unix>,v1=<hex>'
secret: string;
toleranceSeconds?: number; // default 300; 0 disables
now?: () => number; // test seam
}
```
---
## `@gpmpay/sdk/vietqr`
```ts
buildVietQrPayload(input): string // EMVCo string
buildVietQrImageUrl(input): string // img.vietqr.io URL, template selectable
buildPaymentInstructions(request) // everything a checkout page needs
crc16ccitt(input): string
```
```ts
interface PaymentRequest {
bankAccount: BankAccount & { bank?: Bank }; // from client.bankAccounts.*
amount: number | string;
transferContent: string; // YOUR code
serviceCode?: 'QRIBFTTA' | 'QRIBFTTC';
template?: 'compact' | 'compact2' | 'qr_only' | 'print';
}
interface PaymentInstructions {
qrPayload: string;
qrImageUrl: string;
amount: number;
transferContent: string;
bankName: string | undefined;
bankBin: string | undefined;
accountNumber: string;
accountName: string;
}
```
Throws `GpmPayConfigError` when `bankAccount.bank.bin` is missing — without the
BIN there is no valid VietQR payload to build.
> Reconciliation codes are **yours**. GPM Pay mints none, so the SDK ships no
> code generator or parser: use whatever format your system already has, and
> match it with your own regex.
---
## Shared utilities
```ts
import { toVnd, formatVnd, normalizeBaseUrl, maskToken, tokenPrefix } from '@gpmpay/sdk';
toVnd('50000') // 50000
formatVnd('50000') // '50.000 ₫'
normalizeBaseUrl('api.gpmpay.com') // 'https://api.gpmpay.com/api/v1'
maskToken(token) // 'gpm_a1b2c3d4••••••••'
```
---
## CLI
```
gpmpay ping Validate the token and probe each scope
gpmpay accounts list [--status <s>] [--limit <n>] — source of --account
gpmpay accounts get <id>
gpmpay transactions list [--limit <n>] [--account <uuid>] [--type IN|OUT]
gpmpay simulate tx --account <uuid> --amount <vnd> --content <text>
[--type IN|OUT] [--allow-production]
gpmpay webhook send --url <url> [--secret <s>] [--amount <vnd>] [--content <text>]
[--file <body.json>]
[--skew <s>] [--bad-signature]
gpmpay webhook listen [--port 4444] [--secret <s>]
gpmpay webhook verify --signature "t=..,v1=.." [--file body.json]
gpmpay webhook settings [--driver <d>] [--limit <n>]
gpmpay webhook history [--status <s>] [--setting <id>] [--limit <n>]
gpmpay webhook retry <id>
--token --base-url --sandbox --json --no-color -h -v
```
Exit codes: `0` ok · `1` generic error · `2` bad usage / missing token · `3`
authentication failure · `4` network / timeout.
Every command accepts `--json`. That output **redacts** secret fields
(`ingestSecret`, `authorizationSecret`, `telegramBotToken`, `wpSecret`,
`verificationCode`) — the API returns them encrypted, and they have no business
landing in CI logs.
`webhook send` is the only command that needs **no API token**: it signs a
sample payload and POSTs it straight to your handler, never touching the GPM Pay
API.
`simulate` requires a non-production base URL (`--sandbox`); otherwise it stops
at exit code 2 before sending any request.