UNPKG

@gpmpay/sdk

Version:

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

522 lines (394 loc) • 15.6 kB
# Webhooks end to end [Tiếng Việt](../vi/03-webhooks.md) · [Index](./README.md) Webhooks are the primary way to learn that a payment arrived. Polling is the fallback. --- ## 1. Register an endpoint ```ts const { setting, secret } = await client.webhookSettings.createHmacEndpoint({ url: 'https://shop.example.com/webhooks/gpmpay', name: 'Production', }); console.log(secret); // ← shown ONCE ``` Store `secret` as `GPMPAY_WEBHOOK_SECRET` immediately. The server keeps it encrypted and **never returns it**. Lose it and you create a new endpoint. Scope the endpoint to specific accounts: ```ts await client.webhookSettings.createHmacEndpoint({ url: 'https://shop.example.com/webhooks/gpmpay', scope: 'SPECIFIC', bankAccountIds: [bankAccountId], }); ``` You can also create endpoints in the dashboard UI — the SDK is not required. --- ## 2. The payload ```ts interface WebhookPayload { id: string; // transaction id — USE AS 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 transferType: 'in' | 'out'; transferAmount: number; // already a number, not a string accumulated: number | null; // balance after, when the bank reports it referenceCode: string; // the BANK's transfer id — NOT your order code source: 'REAL' | 'SIMULATED'; } ``` That is all 11 fields. Two easy mix-ups: - **`content` is where your code lives**, not `referenceCode`. `referenceCode` is the bank's own transfer id and has nothing to do with your order — matching on it never works. This is the most common integration bug. - **You get a webhook for *every* transaction**, including outgoing ones and incoming money that has nothing to do with you. Guard on `transferType === 'in'` and on your own code being present. ```ts const { content, transferAmount, transferType } = event.payload; if (transferType !== 'in') return; const code = /DH(\d+)/.exec(content)?.[0]; // a regex, not `===` if (!code) return; // money in without your code const order = await db.orders.findByCode(code); if (!order || order.total !== transferAmount) return; // you own this check await fulfil(order); ``` --- ## 3. Signature verification **The header GPM Pay sends depends on the webhook setting's `authorizationType`.** The three modes use three different headers — pick the wrong one and you will be hunting for a header that never arrives: | `authorizationType` | Header GPM Pay sends | How to verify | |---|---|---| | `HMAC` *(default)* | `X-GPMPay-Signature: t=<unix>,v1=<hex>` | `constructWebhookEvent()` | | `API_KEY` | A header you name via `authorizationHeaderName`, **defaulting to `Authorization`** | `verifyApiKeyHeader()` | | `NONE` | No authentication header at all, only `Content-Type` | Nothing to verify | Three things that commonly mislead: - **There is no `X-GPMPay-Timestamp` header.** The timestamp lives in the `t=` component inside the signature value. - **The `HTTP` driver does not send `X-GPMPay-Event`** — only the WordPress driver does. `event.type` defaulting to `'transaction.created'` is an SDK-side fallback, not something on the wire. - The enum is `HMAC`, **not** `HMAC_SHA256`. The algorithm is SHA-256; the enum name is not. To see which mode your endpoint uses: ```bash gpmpay webhook settings ``` ### HMAC — the default, and what you should use ``` X-GPMPay-Signature: t=1785600000,v1=3f2a9c...64_hex_chars ``` The signature is `HMAC-SHA256(secret, "${t}.${rawBody}")`, with a ±300 second clock-skew window. See §4 onwards for per-framework code. ### API_KEY — the raw secret in a header GPM Pay sends **the secret verbatim, with no `Bearer` or `ApiKey` prefix**. The header looks like a Bearer token but is not one — do not `slice('Bearer '.length)`. ```ts import { verifyApiKeyHeader } from '@gpmpay/sdk/webhooks'; // Register: the header defaults to `Authorization` if you omit the name. await client.webhookSettings.create({ driver: 'HTTP', url: 'https://shop.example.com/webhooks/gpmpay', scope: 'ALL', authorizationType: 'API_KEY', authorizationHeaderName: 'X-Api-Key', authorizationSecret: process.env.GPMPAY_WEBHOOK_SECRET!, }); // Receive: if (!verifyApiKeyHeader(req.headers['x-api-key'], process.env.GPMPAY_WEBHOOK_SECRET!)) { return res.status(401).end(); } ``` `verifyApiKeyHeader` compares in constant time. **Do not** use `===` — ordinary string comparison short-circuits at the first differing byte and leaks how much of the prefix you got right. API_KEY is weaker than HMAC: there is no timestamp, so it offers no replay protection, and the secret itself crosses the wire on every delivery rather than just a signature. Use it only when the receiving system cannot compute an HMAC. ### NONE — no authentication No header is sent. Anyone who learns the URL can forge a webhook. Use it only for endpoints on an internal network, never one exposed to the Internet. ### The unbreakable rule: use the raw body The HMAC is over the **exact bytes** the server sent. If your framework parsed the JSON and you re-`JSON.stringify` it, key order and whitespace change and the signature will **always** fail. The SDK detects this and throws a configuration error explaining it, instead of leaving you debugging a signature that is "inexplicably wrong". --- ## 4. Framework recipes Open the one you use. Every block does the same thing: get the **raw body**, verify it, handle it, respond 200 quickly. <details open> <summary><b>Express</b></summary> ```ts import express from 'express'; import { gpmpayWebhook } from '@gpmpay/sdk/webhooks'; app.post( '/webhooks/gpmpay', express.raw({ type: 'application/json' }), // ← REQUIRED, this route only gpmpayWebhook({ secret: process.env.GPMPAY_WEBHOOK_SECRET!, onEvent: async (event) => { const code = /DH(\d+)/.exec(event.payload.content)?.[0]; if (code) await fulfil(code, event.payload.id); }, onError: (error) => { logger.warn({ reason: error.reason }, 'rejected GPM Pay webhook'); }, }), ); ``` If the app has a global `express.json()`, don't remove it — capture the raw body with its `verify` hook: ```ts app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; }, })); ``` The middleware prefers `req.rawBody` when present. </details> <details> <summary><b>Next.js — App Router</b></summary> ```ts // app/api/webhooks/gpmpay/route.ts import { createNextWebhookHandler } from '@gpmpay/sdk/webhooks'; export const POST = createNextWebhookHandler({ secret: process.env.GPMPAY_WEBHOOK_SECRET!, onEvent: async (event) => { const code = /DH(\d+)/.exec(event.payload.content)?.[0]; if (code) await fulfil(code); }, }); ``` `await request.text()` gives the exact raw bytes, so no extra configuration is needed. For more control: ```ts import { verifyNextRequest } from '@gpmpay/sdk/webhooks'; import { GpmPayWebhookSignatureError } from '@gpmpay/sdk'; export async function POST(request: Request) { try { const event = await verifyNextRequest(request, { secret: process.env.GPMPAY_WEBHOOK_SECRET!, }); // ... return Response.json({ received: true }); } catch (error) { if (error instanceof GpmPayWebhookSignatureError) { return Response.json({ error: error.reason }, { status: 401 }); } throw error; } } ``` </details> <details> <summary><b>Next.js — Pages Router</b></summary> ```ts import { readRawBody, constructWebhookEvent } from '@gpmpay/sdk/webhooks'; export const config = { api: { bodyParser: false } }; // ← REQUIRED export default async function handler(req, res) { const rawBody = await readRawBody(req); const event = constructWebhookEvent({ rawBody, signature: req.headers['x-gpmpay-signature'], secret: process.env.GPMPAY_WEBHOOK_SECRET!, headers: req.headers, }); res.status(200).json({ received: true }); } ``` </details> <details> <summary><b>Fastify</b></summary> Register a parser that keeps the buffer intact, then verify as anywhere else: ```ts fastify.addContentTypeParser( 'application/json', { parseAs: 'buffer' }, (_req, body, done) => done(null, body), ); fastify.post('/webhooks/gpmpay', async (req, reply) => { const event = constructWebhookEvent({ rawBody: req.body as Buffer, signature: req.headers['x-gpmpay-signature'] as string, secret: process.env.GPMPAY_WEBHOOK_SECRET!, }); await reply.send({ received: true }); }); ``` </details> <details> <summary><b>Hono / Cloudflare Workers / Deno</b></summary> ```ts app.post('/webhooks/gpmpay', async (c) => { const event = constructWebhookEvent({ rawBody: await c.req.text(), // already the exact bytes signature: c.req.header('x-gpmpay-signature') ?? '', secret: c.env.GPMPAY_WEBHOOK_SECRET, }); return c.json({ received: true }); }); ``` </details> <details> <summary><b>Any other framework</b></summary> Once you have the raw body, everything is the same: ```ts import { assertWebhookSignature, constructWebhookEvent, verifyWebhookSignature, } from '@gpmpay/sdk/webhooks'; // A typed, parsed payload: const event = constructWebhookEvent({ rawBody, signature, secret, headers }); // Just a boolean: const ok = verifyWebhookSignature({ rawBody, signature, secret }); // Need the failure reason: try { assertWebhookSignature({ rawBody, signature, secret }); } catch (error) { error.reason; // 'missing_secret' | 'malformed_header' | 'timestamp_skew' | 'mismatch' } ``` </details> ### `gpmpayWebhook` options (Express) | Option | Default | Meaning | |---|---|---| | `secret` | — | the webhook setting's secret | | `onEvent` | — | handler; receives `(event, req)` | | `onError` | — | called on a bad signature, before the 401 | | `toleranceSeconds` | `300` | clock-skew window | | `headerName` | `X-GPMPay-Signature` | change if you set a different `authorizationHeaderName` | | `respondEarly` | `true` | send 200 before awaiting `onEvent` | --- ## 5. Two properties your handler MUST have ### 5a. Idempotent on `payload.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. ```ts onEvent: async (event) => { const txnId = event.payload.id; // Insert first, guarded by a unique constraint const inserted = await db.processedWebhooks.insertIfAbsent(txnId); if (!inserted) return; // already handled const code = /DH(\d+)/.exec(event.payload.content)?.[0]; if (code) { await fulfil(code); } } ``` The constants are exported: ```ts import { WEBHOOK_RETRY_SCHEDULE_SECONDS, WEBHOOK_MAX_ATTEMPTS } from '@gpmpay/sdk/webhooks'; // [10, 30, 120, 600, 3600, 21600] · 6 ``` ### 5b. Respond within 5 seconds The server aborts a delivery after **5000ms**. A slow handler counts as a failure, gets retried, and duplicates work. `gpmpayWebhook` already sends `200` **before** awaiting `onEvent` (`respondEarly: true`). Push heavy work onto a queue: ```ts onEvent: async (event) => { await queue.add('fulfil-order', { transactionId: event.payload.id }); } ``` If you write the handler by hand, you own this property. --- ## 6. Debugging locally ### Fire straight at your handler — no token, no ngrok ```bash npx gpmpay webhook send --url http://localhost:3000/webhooks/gpmpay \ --secret $GPMPAY_WEBHOOK_SECRET ``` This signs a sample payload and POSTs it to the URL you name. It **makes no GPM Pay API call**, so it needs no `GPMPAY_API_TOKEN` — you can use it from minute one, before you even have an account. Your handler must **reject** both of these. If it answers 200, verification is not actually running: ```bash npx gpmpay webhook send --url http://localhost:3000/webhooks/gpmpay \ --secret $GPMPAY_WEBHOOK_SECRET --bad-signature # bad signature → must 401 npx gpmpay webhook send --url http://localhost:3000/webhooks/gpmpay \ --secret $GPMPAY_WEBHOOK_SECRET --skew 600 # outside ±300s → must 401 ``` Other flags: `--amount`, `--content` (try Vietnamese diacritics — that is where hand-rolled verification breaks), `--content "CT DEN DH123"` for the matched branch, and `--file body.json` to send a body of your own. ### Receive real deliveries over ngrok ```bash npx gpmpay webhook listen --port 4444 --secret $GPMPAY_WEBHOOK_SECRET ``` ``` ✓ Listening for GPM Pay webhooks on http://localhost:4444 ``` The listener verifies every request for real and pretty-prints the payload. It answers `200` when valid and `401` when not — exactly what your endpoint must do. With ngrok plus the simulator you get an end-to-end demo in a minute: ```bash ngrok http 4444 npx gpmpay accounts list # get a bankAccountId npx gpmpay simulate tx --sandbox --account <uuid> --amount 50000 --content TEST ``` Verify a single signature from a log: ```bash echo '{"id":"tx_1"}' | npx gpmpay webhook verify \ --secret $GPMPAY_WEBHOOK_SECRET \ --signature "t=1785600000,v1=3f2a9c..." ``` --- ## 7. Delivery history ```bash npx gpmpay webhook settings # which endpoints are live, and their auth mode npx gpmpay webhook history --status FAILED npx gpmpay webhook retry <history-id> ``` `webhook history` prints the HTTP status your endpoint returned, the attempt count, the response time, and the head of the response body — enough to tell "the handler returned 500" from "the handler timed out" without opening the dashboard. The same thing in code: ```ts const history = await client.webhookHistories.list({ status: 'FAILED', settingId: setting.id, }); await client.webhookHistories.retry(history.data[0]!.id); // manual redelivery ``` An already-`DELIVERED` attempt cannot be retried, and a disabled setting must be re-enabled first. --- ## 8. Troubleshooting | Symptom | Cause | |---|---| | Always `mismatch` even with the right secret | Body was parsed — missing `express.raw()` / `bodyParser: false` | | `mismatch` only for Vietnamese content | Hand-rolled verification hashing a string instead of a Buffer. Use the SDK helpers. | | `timestamp_skew` | Server clock drift. Enable NTP. | | `malformed_header` | Header stripped by a proxy, or wrong header name | | Transactions processed 2–3 times | Handler is not idempotent, or responds slower than 5s | | No webhooks at all | Setting is `isActive: false`, wrong account scope, or the URL is not publicly reachable | | Webhook arrives for money you did not expect | You receive *every* transaction on the account — filter on `transferType` and your own code | For simulated transactions, the webhook setting must have `fireOnSimulated: true` (the default). --- ## 9. Security - **Always verify the signature.** The endpoint is public; anyone can POST to it. - Keep the secret in an environment variable, never committed. - Do not IP-allowlist — deliveries may go through an egress proxy, so the source IP is not stable. - Trust `event.payload` only. Ignore query strings and anything outside the signed body. - Cross-check the amount against your own record before fulfilling: ```ts const local = await db.orders.findByCode(code); if (!local || local.amount !== event.payload.transferAmount) { logger.error({ code }, 'webhook amount does not match local order'); return; } ```