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.
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
- Node.js
- Python
- PHP
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"
}'
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.
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()
$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 / 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
- Python
- PHP
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).
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
$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.
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
{
"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: webhooks compared with IPN and polling.
- Receive transfers without orders:
transaction.received. - Webhooks reference.