@gpmpay/sdk
Version:
Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.
522 lines (394 loc) • 15.6 kB
Markdown
# 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;
}
```