# 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](https://hatang.dev/docs/guides/sdks/node.md).

## Install and configure

```bash
npm install @hatang/sdk
```

```ts title="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",
});
```

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

```ts
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: "accounting@example.com" },
    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](https://hatang.dev/docs/guides/payments/create-payment.md#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`.

```ts title="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
  }
}
```

```ts title="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 {#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:

```ts
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:

```ts
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:

```ts
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

```ts
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](https://hatang.dev/docs/reference/errors.md).

## 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](https://hatang.dev/docs/guides/payments/verify-ipn.md) without the SDK.
- [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md): every field and status.
- [Node.js SDK](https://hatang.dev/docs/guides/sdks/node.md): the client for every Ha Tang product.

Source: https://hatang.dev/docs/guides/payments/sdk-javascript
