UNPKG

@gpmpay/sdk

Version:

Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.

358 lines (281 loc) • 12.6 kB
# 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.