# Webhooks

Webhooks send Payments events for your whole business to one or more endpoints: every payment, every bank transfer, every bank account change and every e-invoice change, in both environments. Use them for transfers without orders (`transaction.received`) and for anything an IPN does not cover. This page covers creating an endpoint, verifying deliveries, and every Payments event.

How webhooks work across all Ha Tang products is described in [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md).

## Create an endpoint

In the dashboard, open **Webhooks** and add an endpoint, or call `POST /webhooks`. One endpoint receives both environments; each payments object says which one in its `environment` field.

**cURL**

```bash
curl https://api.hatang.dev/webhooks \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://shop.example.com/hatang/webhooks",
    "events": ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"],
    "description": "Shop backend"
  }'
```

**Node.js**

```ts
const { data: hook, error } = await hatang.webhooks.create({
  endpoint: "https://shop.example.com/hatang/webhooks",
  events: ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"],
  description: "Shop backend",
});
// Store hook.signing_secret (whsec_…) on your server.
```

**Python**

```python
hook = requests.post("https://api.hatang.dev/webhooks", headers=HEADERS, json={
    "endpoint": "https://shop.example.com/hatang/webhooks",
    "events": ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"],
    "description": "Shop backend",
}, timeout=10).json()
```

**PHP**

```php
$hook = json_decode($client->post('/webhooks', ['json' => [
    'endpoint' => 'https://shop.example.com/hatang/webhooks',
    'events' => ['payment.succeeded', 'payment.partially_paid', 'payment.overpaid', 'transaction.received', 'invoice.issued'],
    'description' => 'Shop backend',
]])->getBody(), true);
```

The answer (`201`) includes `signing_secret` (`whsec_…`). It is shown only when the endpoint is created or its secret is rotated, so store it on your server right away.

## Verify deliveries

Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com/) / Svix format. Each one carries `svix-id`, `svix-timestamp` and `svix-signature` (and the same values as `webhook-id`, `webhook-timestamp` and `webhook-signature`).

**Node.js**

```ts
import { constructWebhookEvent, HatangSignatureError } from "@hatang/sdk";

app.post("/hatang/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await constructWebhookEvent(req.body, req.headers, process.env.HATANG_WEBHOOK_SECRET!); // whsec_…
    if (await alreadyHandled(req.get("svix-id"))) return res.sendStatus(200);
    await queue.add("hatang-webhook", event); // { type, created_at, data }
    res.sendStatus(200);
  } catch (err) {
    if (err instanceof HatangSignatureError) return res.sendStatus(401);
    throw err;
  }
});
```

The `svix` package works too: `new Webhook(secret).verify(rawBody, headers)`.

**Python**

```python
import os
from flask import Flask, request, abort
from hatang import HatangSignatureError, construct_webhook_event

SECRET = os.environ["HATANG_WEBHOOK_SECRET"]  # whsec_...
app = Flask(__name__)

@app.post("/hatang/webhooks")
def hatang_webhooks():
    try:
        event = construct_webhook_event(request.get_data(), request.headers, SECRET)  # { type, created_at, data }
    except HatangSignatureError:
        abort(401)
    # dedupe on the svix-id header, then queue the work
    return "", 200
```

**PHP**

```php
<?php
$secret = base64_decode(substr(getenv('HATANG_WEBHOOK_SECRET'), strlen('whsec_')));
$raw = file_get_contents('php://input');
$id = $_SERVER['HTTP_SVIX_ID'] ?? '';
$ts = $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? '0';
if (abs(time() - (int) $ts) > 300) { http_response_code(401); exit; }

$expected = base64_encode(hash_hmac('sha256', "$id.$ts.$raw", $secret, true));
$ok = false;
foreach (explode(' ', $_SERVER['HTTP_SVIX_SIGNATURE'] ?? '') as $part) {
    [$version, $sig] = array_pad(explode(',', $part, 2), 2, '');
    if ($version === 'v1' && hash_equals($expected, $sig)) { $ok = true; }
}
if (!$ok) { http_response_code(401); exit; }

$event = json_decode($raw, true); // ['type' => ..., 'created_at' => ..., 'data' => ...]
// dedupe on $id, then queue the work
http_response_code(200);
```

**By hand:** base64-decode the part of the secret after `whsec_`, compute HMAC-SHA256 of `{svix-id}.{svix-timestamp}.{raw body}`, base64-encode it, and compare it with each `v1,<signature>` in `svix-signature` (space-separated). Reject timestamps more than 5 minutes away from now.

> **Tip: Rotating the secret**
>
> `POST /webhooks/{id}/rotate` (or the dashboard) issues a new secret and returns it as `secret`. For 24 hours, deliveries are signed with both the new and the previous secret, so `svix-signature` holds two signatures and either one verifies. Deploy the new secret within that window.

## Delivery

| | |
|---|---|
| Body | `{ "type": "...", "created_at": "...", "data": { ... } }` |
| Success | Any `2xx` within 15 seconds. Redirects are not followed |
| Retries | After 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h: 8 attempts |
| Dedupe | `svix-id` is the same on every attempt of one delivery |
| Log | `GET /webhooks/{id}/deliveries`, and **Webhooks** in the dashboard |

## Payments events

### Payments

| Event | When | `data` |
|---|---|---|
| `payment.created` | A payment was created | The payment |
| `payment.succeeded` | The full amount arrived | The payment, plus `transaction` |
| `payment.partially_paid` | Less than the amount arrived | The payment, plus `transaction` |
| `payment.overpaid` | More than the amount arrived | The payment, plus `transaction` |
| `payment.expired` | `expires_at` passed with nothing paid | The payment |
| `payment.canceled` | You canceled it | The payment |

When a payment was settled by hand (`mark-paid`), `data` carries `manual: { by, note, amount }` instead of `transaction`.

### Transactions and bank accounts

| Event | When | `data` |
|---|---|---|
| `transaction.received` | A new transfer on a linked account, within the account's filters | The transaction, plus `merchant_code` |
| `bank_connection.activated` | An account was linked | The bank connection |
| `bank_connection.revoked` | An account was unlinked | The bank connection |
| `bank_connection.needs_reauth` | The bank wants the account linked again | The bank connection |

### E-invoices

| Event | When |
|---|---|
| `invoice.draft_created` / `invoice.draft_updated` / `invoice.draft_deleted` | The draft changed |
| `invoice.issued` | Signed: it has a number |
| `invoice.tax_accepted` / `invoice.tax_rejected` | The tax office's answer |
| `invoice.canceled` / `invoice.replaced` / `invoice.adjusted` | The invoice was canceled, replaced or adjusted |
| `invoice.failed` | The provider refused a step |
| `invoice.conflict` | The draft was edited on both sides; settle it with `POST /einvoices/{id}/resolve` |

`data` is the invoice (`object: "einvoice"`), with `payment_id` or `transaction_id` when it is linked. Events caused on the provider's site carry `source: "provider"`.

## Example: a payment that succeeded

```json
{
  "type": "payment.succeeded",
  "created_at": "2026-09-19T09:34:12.950Z",
  "data": {
    "object": "payment",
    "id": "pay_01a0b904297873e8b3524a26fdca0ab5",
    "environment": "production",
    "status": "paid",
    "amount": 150000,
    "paid_amount": 150000,
    "code": "HTBF7RDGVQ",
    "metadata": { "order_id": "1001" },
    "invoice": null,
    "paid_at": "2026-09-19T09:34:12.940Z",
    "transaction": {
      "object": "transaction",
      "id": "txn_01a0b90429dc79c7a5689c7a0c485a01",
      "amount": 150000,
      "description": "CK HTBF7RDGVQ thanh toan",
      "reference": "FT1789810452930810",
      "payment_id": "pay_01a0b904297873e8b3524a26fdca0ab5",
      "match_status": "matched"
    }
  }
}
```

The payment and transaction objects are shortened here; webhooks carry every field.

## Next steps

- [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md): webhooks compared with IPN and polling.
- [Receive transfers without orders](https://hatang.dev/docs/guides/payments/transactions.md): `transaction.received`.
- [Webhooks reference](https://hatang.dev/docs/reference/webhooks.md).

Source: https://hatang.dev/docs/guides/payments/webhooks
