JavaScript SDK
@hatang/sdk wraps the whole Payments integration: creating orders, confirming payment, and verifying IPN calls and webhooks. Every API method returns { data, error } and never throws for an API error. The verification helpers use only Web Crypto, so they also run on Bun, Deno, Vercel and Cloudflare Workers.
This page covers the payments part. The client itself (options, errors, other products) is described in Node.js SDK.
Install and configure
npm install @hatang/sdk
import { Hatang } from "@hatang/sdk";
export const hatang = new Hatang(process.env.HATANG_API_KEY, {
baseUrl: "https://api.hatang.dev",
// Remove in production, or set HATANG_ENVIRONMENT=development instead.
environment: "development",
});
| Option | Environment variable | Meaning |
|---|---|---|
| first argument | HATANG_API_KEY | Your API key (ht_…) with payments:read and payments:write |
baseUrl | HATANG_BASE_URL | https://api.hatang.dev |
environment | HATANG_ENVIRONMENT | development or production, sent as the Hatang-Environment header |
1. Create the order's payment
createOrder uses your order id as the idempotency key: calling it twice for the same order (a double click, a retried request) returns the same payment. It also stores the id in metadata.order_id.
const { data: payment, error } = await hatang.payments.createOrder({
order_id: "SO-1001",
amount: 150_000,
description: "Order #1001",
return_url: "https://shop.example.com/orders/SO-1001",
ipn_url: "https://shop.example.com/api/hatang/ipn",
// Optional: issue the e-invoice once it is paid.
invoice: {
items: [{ name: "Service plan", quantity: 1, unit_price: 136_364, vat_rate: 10 }],
},
});
if (error) throw new Error(`${error.name}: ${error.message}`);
// Send the payer to the hosted checkout page…
redirect(payment.checkout_url);
// …or show the QR yourself: <img src={payment.qr.image_url} />, the bank in payment.bank, the code in payment.code.
Add code: "SO1001" to use your own order number as the transfer description instead of the generated HT… code. See The payment code.
Calling createOrder again for the same order_id with different fields answers invalid_idempotent_request. Use a new order id when the order changes.
2. Receive the IPN
Pass the raw body and the headers. constructIpnEvent checks the HMAC signature and returns the parsed call, or throws HatangSignatureError.
import { constructIpnEvent, HatangSignatureError } from "@hatang/sdk";
export async function POST(req: Request) {
const raw = await req.text(); // before any JSON parsing
try {
const ipn = await constructIpnEvent(raw, req.headers, process.env.HATANG_SECRET_KEY!);
if (await alreadyHandled(ipn.id)) return new Response(null, { status: 200 });
const order = ipn.data.metadata.order_id as string;
if (ipn.data.status === "paid" || ipn.data.status === "overpaid") await markOrderPaid(order, ipn.data.paid_amount);
if (ipn.data.invoice?.status === "issued") await saveInvoiceNumber(order, ipn.data.invoice.number);
return new Response(null, { status: 200 });
} catch (err) {
if (err instanceof HatangSignatureError) return new Response(null, { status: 401 });
throw err; // a 5xx makes Hatang retry later
}
}
import express from "express";
import { constructIpnEvent } from "@hatang/sdk";
app.post("/api/hatang/ipn", express.raw({ type: "application/json" }), async (req, res) => {
const ipn = await constructIpnEvent(req.body, req.headers, process.env.HATANG_SECRET_KEY!).catch(() => null);
if (!ipn) return res.sendStatus(401);
await queue.add("hatang-ipn", ipn); // handle it in the background, answer fast
res.sendStatus(200);
});
express.json() would parse and re-serialize the body, and the signature would no longer match. Mount express.raw() on the IPN route.
3. Confirm before you fulfil
When the payer comes back to your return_url, ask Ha Tang instead of trusting the browser. verify returns verified: true only if the payment is paid (or overpaid) and matches what you pass:
const { data: check, error } = await hatang.payments.verify(paymentId, {
order_id: "SO-1001",
amount: 150_000,
environment: "production", // a development payment never unlocks a real order
});
if (error) return showError(error.message);
if (check.verified) await fulfil("SO-1001");
else console.log(check.reasons); // e.g. ["status is partially_paid"], ["belongs to another order"]
reasons entry | Meaning |
|---|---|
status is <status> | Not paid or overpaid yet |
paid <n>, expected <amount> | Paid, but less than the amount you expect |
belongs to another order | metadata.order_id is not the order_id you passed |
is a <environment> payment | The payment is from the other environment |
4. Webhooks (optional)
Webhooks cover everything in your business, not just one payment:
import { constructWebhookEvent, type Payment } from "@hatang/sdk";
const event = await constructWebhookEvent<Payment>(raw, req.headers, process.env.HATANG_WEBHOOK_SECRET!); // whsec_…
if (event.type === "payment.succeeded") await markOrderPaid(event.data.metadata.order_id as string, event.data.paid_amount);
constructWebhookEvent throws HatangSignatureError when the signature is wrong or the timestamp is more than 5 minutes away. Pass { toleranceSec } as a fourth argument to change the window.
5. Test in development
With environment: "development" and the development bank linked:
await hatang.payments.simulateTransfer({ payment_id: payment.id }); // pays it in full
await hatang.payments.simulateTransfer({ payment_id: payment.id, amount: 50_000 }); // a partial transfer
Handle errors
const { data, error } = await hatang.payments.create({ amount: 150_000, code: "SO1001" });
if (error) {
switch (error.name) {
case "code_taken": // 409: that code is already used on this bank account
case "no_bank_connection": // 422: link a bank account first
case "validation_error": // 422: error.message names the field
return showError(error.message);
case "rate_limit_exceeded": // 429: slow down and retry
return retryLater();
default:
throw new Error(`${error.statusCode} ${error.name}: ${error.message}`);
}
}
A network failure comes back as error.name === "application_error" with statusCode: 0. The full list is in Errors.
Method reference
| Method | Does |
|---|---|
payments.createOrder({ order_id, amount, … }) | Create the payment for an order; safe to retry |
payments.create(payload, { idempotencyKey }) | The same, with your own key and metadata |
payments.verify(id, { order_id, amount, environment }) | { verified, paid, reasons, payment } |
payments.get(id) / list(params) | Read payments |
payments.cancel(id) / markPaid(id, { amount, note }) | Cancel a pending payment, or record money received outside the bank feed |
payments.ipnDeliveries(id) | The IPN calls made for a payment |
payments.qr({ amount, content, connection_id }) | A VietQR for the transfers-only flow |
payments.simulateTransfer(…) | Development only |
payments.transactions.list(params) / get(id) | Bank transactions |
payments.bankConnections.list / get / create / confirm / update / unlink / confirmUnlink | Bank accounts |
payments.statements.import({ connection_id, content }) / get(id) | Statement import |
einvoices.create / get / list / update / remove / publish / retry / replace / resolve / sync / history | E-invoices |
einvoices.providers() / einvoices.connections.* | E-invoice provider accounts |
constructIpnEvent(raw, headers, secretKey) / verifyIpn(raw, signature, secretKey) | IPN signature (HMAC-SHA256) |
constructWebhookEvent(raw, headers, secret) / verifyWebhook(raw, headers, secret) | Webhook signature (Svix), 5-minute window |
Next steps
- Verify IPN signatures without the SDK.
- Create a payment: every field and status.
- Node.js SDK: the client for every Ha Tang product.