Skip to main content

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 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"
}'

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).

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).

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.

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": { ... } }
SuccessAny 2xx within 15 seconds. Redirects are not followed
RetriesAfter 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h: 8 attempts
Dedupesvix-id is the same on every attempt of one delivery
LogGET /webhooks/{id}/deliveries, and Webhooks in the dashboard

Payments events​

Payments​

EventWhendata
payment.createdA payment was createdThe payment
payment.succeededThe full amount arrivedThe payment, plus transaction
payment.partially_paidLess than the amount arrivedThe payment, plus transaction
payment.overpaidMore than the amount arrivedThe payment, plus transaction
payment.expiredexpires_at passed with nothing paidThe payment
payment.canceledYou canceled itThe payment

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

Transactions and bank accounts​

EventWhendata
transaction.receivedA new transfer on a linked account, within the account's filtersThe transaction, plus merchant_code
bank_connection.activatedAn account was linkedThe bank connection
bank_connection.revokedAn account was unlinkedThe bank connection
bank_connection.needs_reauthThe bank wants the account linked againThe bank connection

E-invoices​

EventWhen
invoice.draft_created / invoice.draft_updated / invoice.draft_deletedThe draft changed
invoice.issuedSigned: it has a number
invoice.tax_accepted / invoice.tax_rejectedThe tax office's answer
invoice.canceled / invoice.replaced / invoice.adjustedThe invoice was canceled, replaced or adjusted
invoice.failedThe provider refused a step
invoice.conflictThe 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​

View this page as Markdown