Skip to main content

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
lib/hatang.ts
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",
});
OptionEnvironment variableMeaning
first argumentHATANG_API_KEYYour API key (ht_…) with payments:read and payments:write
baseUrlHATANG_BASE_URLhttps://api.hatang.dev
environmentHATANG_ENVIRONMENTdevelopment 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: {
buyer: { name: "Nguyen Van Khach", tax_code: "0109876543", email: "[email protected]" },
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.

app/api/hatang/ipn/route.ts (Next.js)
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
}
}
Express
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);
});
caution

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 entryMeaning
status is <status>Not paid or overpaid yet
paid <n>, expected <amount>Paid, but less than the amount you expect
belongs to another ordermetadata.order_id is not the order_id you passed
is a <environment> paymentThe 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​

MethodDoes
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 / confirmUnlinkBank accounts
payments.statements.import({ connection_id, content }) / get(id)Statement import
einvoices.create / get / list / update / remove / publish / retry / replace / resolve / sync / historyE-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​

View this page as Markdown