# 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;
}
```
