# Ha Tang developer docs > Ha Tang (Hạ Tầng) is a customer support platform for businesses in Vietnam: one inbox for website chat, mobile apps, Zalo, Messenger, Instagram, TikTok Shop, WhatsApp and email, with an AI assistant, plus a transactional email API, VietQR payments and e-invoices. These are the developer guides and API references. --- # Contacts, segments and topics Before you send a broadcast, you need people to send it to. Ha Tang organizes recipients the way Resend does today: - A **contact** is one email address in your business (case does not matter), with a first and last name, **custom properties** and an "unsubscribed from everything" flag. - A **segment** is a fixed group of contacts you send broadcasts to. A contact can be in many segments; deleting a segment keeps its contacts. Segments used to be called *audiences*, and the `/audiences` API still works. - A **topic** is a kind of email, such as promotions or a weekly newsletter, that each recipient can subscribe to or leave on the preferences page. In the dashboard, everything is under **Email → Audiences**, in the **Contacts**, **Segments**, **Topics**, **Properties** and **Suppressions** tabs. ## Before you start The contact, segment, topic and property endpoints need a key with `email:write`. ## Add contacts **cURL** ```bash curl -X POST https://api.hatang.dev/contacts \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "email": "lan@example.net", "first_name": "Lan", "last_name": "Tran", "properties": { "plan": "pro" }, "segments": [{ "id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10" }], "topics": [{ "id": "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5", "subscription": "opt_in" }] }' # { "object": "contact", "id": "con_…" } ``` **Node.js** ```ts const { data, error } = await hatang.contacts.create({ email: "lan@example.net", first_name: "Lan", last_name: "Tran", properties: { plan: "pro" }, segments: [{ id: "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10" }], topics: [{ id: "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5", subscription: "opt_in" }], }); ``` **Python** ```python requests.post( "https://api.hatang.dev/contacts", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={ "email": "lan@example.net", "first_name": "Lan", "properties": {"plan": "pro"}, "segments": [{"id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10"}], }, ) ``` **PHP** ```php $client->post('/contacts', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'email' => 'lan@example.net', 'first_name' => 'Lan', 'properties' => ['plan' => 'pro'], 'segments' => [['id' => 'aud_0199e6c17a2b7f009c1e3f6a2b1d9e10']], ], ]); ``` | Field | Description | |---|---| | `email` | Required. Unique in your business. | | `first_name`, `last_name` | Up to 100 characters each. | | `unsubscribed` | `true` unsubscribes the contact from everything. | | `properties` | Values for declared properties. `null` removes a value. | | `segments` | Up to 50 `{ "id" }` to add the contact to. | | `topics` | Up to 50 `{ "id", "subscription" }` pairs, where `subscription` is `opt_in` or `opt_out`. | `POST /contacts` creates the contact, or updates it when the address already exists, and returns its id. The `contact.created`, `contact.updated` and `contact.deleted` webhooks fire on changes. A new contact also starts automations triggered by `hatang:contact.created`; see [Automations](https://hatang.dev/docs/guides/email/automations.md). In the dashboard you can also **Add contact** one by one or **Import CSV**. The first row of the CSV is the header with any of these columns: `email`, `first_name`, `last_name`, `unsubscribed` and your property keys. Other columns are ignored and existing addresses are updated. Customers in the CRM can be synced too (**Customers → Sync to Email**); syncing never resubscribes someone who unsubscribed. ## Custom properties Declare a property before you set it on contacts. A property is a `string` or a `number`, with an optional fallback value: ```bash curl -X POST https://api.hatang.dev/contact-properties \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "key": "plan", "type": "string", "fallback_value": "free" }' ``` - Keys use letters, digits and `_`, up to 50 characters. String values are up to 2,000 characters. - Setting a property that is not declared returns `422`. A `number` property refuses values that are not numbers. - Only the fallback can change later (`PATCH /contact-properties/{id}`). Deleting a property removes its value from every contact. Use properties in broadcasts and automation emails as `{{{contact.plan}}}`, next to `{{{contact.first_name}}}`, `{{{contact.last_name}}}` and `{{{contact.email}}}`. A contact without a value gets the inline default (`{{{contact.plan|free}}}`), then the property's fallback. ## Segments ```bash curl -X POST https://api.hatang.dev/segments \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Customers 2026" }' # { "object": "segment", "id": "aud_…", "name": "Customers 2026", "created_at": "…" } curl -X POST https://api.hatang.dev/contacts/lan@example.net/segments/aud_0199e6c17a2b7f009c1e3f6a2b1d9e10 \ -H "Authorization: Bearer $HATANG_API_KEY" ``` Segment names are up to 100 characters. Segment ids start with `aud_`, because a segment is what used to be an audience. ## Topics and the preferences page ```bash curl -X POST https://api.hatang.dev/topics \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Promotions", "description": "Sales and discount codes", "default_subscription": "opt_in", "visibility": "public" }' ``` | Field | Description | |---|---| | `name` | Up to 50 characters. | | `description` | Up to 200 characters, shown on the preferences page. | | `default_subscription` | `opt_in`: contacts get it until they leave. `opt_out`: contacts get it only after they subscribe. Fixed once created. | | `visibility` | `public` shows the topic to everyone on the preferences page. `private` (the default) shows it only to contacts subscribed to it. | How topics filter mail: - A broadcast with a `topic_id` only reaches contacts subscribed to that topic. - `POST /emails` with a `topic_id` skips recipients who left that topic; they are reported as suppressed. Addresses that are not contacts follow the topic's default. - A contact's choices are in `GET /contacts/{id}/topics` and change with `PATCH /contacts/{id}/topics`: ```bash curl -X PATCH https://api.hatang.dev/contacts/lan@example.net/topics \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '[{ "id": "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5", "subscription": "opt_out" }]' ``` The `{{{UNSUBSCRIBE_URL}}}` link in a broadcast opens the **preferences page**, where the recipient switches each topic on or off or unsubscribes from everything. Opening the page changes nothing, so link scanners cannot unsubscribe anyone by accident. The page is in English when the browser prefers English and in Vietnamese otherwise. Set its title, description, logo and colors under **Email settings → Unsubscribe page**. How one-click unsubscribe works is in [Broadcasts](https://hatang.dev/docs/guides/email/broadcasts.md#unsubscribe). ## API reference | Endpoint | Description | |---|---| | `POST /contacts`, `GET /contacts` | Create or update a contact; list contacts (`limit`, `after`, `segment_id`, `q`). | | `GET`, `PATCH`, `DELETE /contacts/{id or email}` | One contact. | | `GET /contacts/{id}/segments`, `POST` or `DELETE /contacts/{id}/segments/{segment_id}` | Segment membership. | | `GET`, `PATCH /contacts/{id}/topics` | Topic choices. | | `POST /segments`, `GET /segments`, `GET`, `PATCH`, `DELETE /segments/{id}`, `GET /segments/{id}/contacts` | Segments. `GET /segments/{id}` includes `contact_count`. | | `POST /topics`, `GET /topics`, `GET`, `PATCH`, `DELETE /topics/{id}` | Topics. | | `POST /contact-properties`, `GET /contact-properties`, `PATCH`, `DELETE /contact-properties/{id}` | Properties. | | `/audiences`, `/audiences/{id}/contacts` | The older audience API, kept for existing Resend and Ha Tang clients. An audience is a segment. | The Node.js SDK has the same calls under `hatang.contacts`, `hatang.segments`, `hatang.topics`, `hatang.contactProperties` and `hatang.audiences`. ## Next steps - [Broadcasts](https://hatang.dev/docs/guides/email/broadcasts.md) - [Automations](https://hatang.dev/docs/guides/email/automations.md) - [Suppressions and bounces](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md) Source: https://hatang.dev/docs/guides/email/audiences --- # Automations An automation is a sequence of steps that runs for one contact when something happens in your app: a welcome email when someone signs up, a thank-you after a payment, a reminder if a customer has not left a review three days later. Your app sends events; Ha Tang runs the steps. The API works like Resend Automations. ## Before you start - Published [templates](https://hatang.dev/docs/guides/email/templates.md) for the emails the automation sends, with a `From` address on a verified domain. - For the API, a key with `email:write`. ## 1. Define your events An automation starts from an **event**. Define events under **Email → Automations → Events** or with the API. A schema is optional; when you give one, Ha Tang checks and converts the payload fields (`string`, `number`, `boolean`, `date`): **cURL** ```bash curl -X POST https://api.hatang.dev/events \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "order.paid", "schema": { "amount": "number", "order_id": "string" } }' ``` **Node.js** ```ts await hatang.events.create({ name: "order.paid", schema: { amount: "number", order_id: "string" } }); ``` **Python** ```python requests.post( "https://api.hatang.dev/events", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={"name": "order.paid", "schema": {"amount": "number", "order_id": "string"}}, ) ``` **PHP** ```php $client->post('/events', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['name' => 'order.paid', 'schema' => ['amount' => 'number', 'order_id' => 'string']], ]); ``` - Event names use letters, digits and `.`, `_`, `:`, `-`, up to 100 characters. - Names starting with `hatang:` or `resend:` are reserved for built-in events. Today there is one: `hatang:contact.created`, fired when a contact is created through the API, the dashboard or a CSV import. ## 2. Send events from your app Send an event for a contact by `contact_id`, or by `email`. With `email`, Ha Tang creates the contact if it does not exist yet. **cURL** ```bash curl -X POST https://api.hatang.dev/events/send \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "event": "order.paid", "email": "lan@example.net", "payload": { "amount": 2000000, "order_id": "DH1024" } }' ``` **Node.js** ```ts const { data } = await hatang.events.send({ event: "order.paid", email: "lan@example.net", payload: { amount: 2000000, order_id: "DH1024" }, }); console.log(data.runs_started, data.runs_resumed); ``` **Python** ```python hatang.events.send("order.paid", email="lan@example.net", payload={"amount": 2000000, "order_id": "DH1024"}) ``` **PHP** ```php $client->post('/events/send', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['event' => 'order.paid', 'email' => 'lan@example.net', 'payload' => ['amount' => 2000000, 'order_id' => 'DH1024']], ]); ``` The answer is `202`: ```json { "object": "event", "event": "order.paid", "contact_id": "con_0199e6c17a2b7f009c1e3f6a2b1d9e10", "runs_started": 1, "runs_resumed": 0 } ``` `runs_started` counts the enabled automations this event started for the contact; `runs_resumed` counts the runs that were waiting for it. | Error | Cause | |---|---| | `422` "Unknown event" | The event is not defined. Create it with `POST /events` first. | | `422` "must be a number" (or boolean, date) | A payload field does not match the schema. Fields not in the schema pass through unchanged. | | `422` "Pass contact_id or email" | The request names no contact. | ## 3. Build the flow Open **Email → Automations**, create an automation and: 1. Choose the event that starts it (**Start from an event**). 2. Click **+** under a step to add the next one. 3. Configure the selected step in the panel on the right. 4. **Save**, then **Enable**. | Step | Configuration | Branches | |---|---|---| | `trigger` | `event_name` | | | `send_email` | `template.id` (a **published** template), optional `from` and `subject` overriding the template, `template.variables` | | | `delay` | `duration`, for example `"30 minutes"`, `"2 days"`, `"1 week"`. At most 30 days. | | | `wait_for_event` | `event_name`, `timeout` (default `"30 days"`), optional `filter_rule` on the incoming event | `event_received`, `timeout` | | `condition` | `rule` | `condition_met`, `condition_not_met` | | `update_contact` | `properties` (declared properties), `unsubscribed` | | | `add_to_segment` | `segment_id` | | When a `send_email` step runs: - Template variables come from the step's `template.variables` first, then from the event payload, then from the contact (`contact.first_name`, `contact.`…). - `{{{UNSUBSCRIBE_URL}}}` points to the contact's preferences page. - A contact who unsubscribed from everything is skipped (the step is marked `skipped`). - Each email is tagged `automation_id` and appears under **Email → Emails** like any other email. Ha Tang refuses to enable an automation without a trigger, with a template that is not published, or with a `send_email` step that has no `From` address in the step or the template. ### Conditions A `rule` compares one field with a value: ```json { "field": "event.amount", "operator": "gte", "value": 1000000 } ``` - **Fields**: - `event.*`: the payload of the event that started the run. - `contact.*`: `email`, `first_name`, `last_name`, `unsubscribed` and custom properties. - `wait_events..*`: the payload received by a `wait_for_event` step. - **Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `starts_with`, `ends_with`, `exists`, `is_empty`. - **Compare with another field**: `"value": { "var": "contact.limit" }`. - **Combine rules**: `{ "type": "and", "rules": [ … ] }` or `{ "type": "or", "rules": [ … ] }`. `filter_rule` on `wait_for_event` uses the same shape, with `event.*` being the incoming event. A waiting run only resumes for an event that passes the filter. ### Create with the API ```bash curl -X POST https://api.hatang.dev/automations \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "After payment", "status": "enabled", "steps": [ { "key": "start", "type": "trigger", "config": { "event_name": "order.paid" } }, { "key": "thanks", "type": "send_email", "config": { "template": { "id": "order-confirmed" } } }, { "key": "big", "type": "condition", "config": { "rule": { "field": "event.amount", "operator": "gte", "value": 1000000 } } }, { "key": "vip", "type": "add_to_segment", "config": { "segment_id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10" } }, { "key": "review", "type": "wait_for_event", "config": { "event_name": "review.left", "timeout": "3 days" } }, { "key": "remind", "type": "send_email", "config": { "template": { "id": "review-reminder" } } } ], "connections": [ { "from": "start", "to": "thanks" }, { "from": "thanks", "to": "big" }, { "from": "big", "to": "vip", "type": "condition_met" }, { "from": "big", "to": "review", "type": "condition_not_met" }, { "from": "review", "to": "remind", "type": "timeout" } ] }' # { "object": "automation", "id": "auto_…" } ``` - An automation has exactly one `trigger` step, at most 100 steps and 200 connections. - Step keys are unique, with letters, digits, `-` and `_`, up to 64 characters. - Nothing may lead back to the trigger. ## Enable, edit, stop - The steps of an **enabled** automation cannot change. Disable it to edit it, or **Duplicate** it into a new one. - A run keeps the version of the automation it started with, even if you edit the automation later. - `POST /automations/{id}/stop` disables the automation and cancels every run that has not finished. - `POST /automations/{id}/duplicate` creates a disabled copy. ## Runs Each time a contact enters an automation, Ha Tang creates a **run**. The **Runs** tab lists them by status: | Status | Meaning | |---|---| | `running` | Executing steps. | | `waiting` | Waiting for a `delay` to end or for an event. | | `completed` | Reached the end of the flow. | | `failed` | A step failed; the run shows the error. | | `cancelled` | Stopped, for example by `POST /automations/{id}/stop`. | Open a run to see each step: when it ran, its result (for example the id of the email it sent) and any error. ```bash curl "https://api.hatang.dev/automations/auto_…/runs?status=failed,waiting" -H "Authorization: Bearer $HATANG_API_KEY" curl "https://api.hatang.dev/automations/auto_…/runs/arun_…" -H "Authorization: Bearer $HATANG_API_KEY" ``` The flow editor has a **Send a test event** box: enter an email address and a payload to run an enabled automation for that contact. ## SDK ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); await hatang.events.create({ name: "order.paid", schema: { amount: "number" } }); await hatang.events.send({ event: "order.paid", email: "lan@example.net", payload: { amount: 2000000 } }); const { data } = await hatang.automations.runs.list("auto_…", { status: "failed" }); await hatang.automations.stop("auto_…"); ``` ## API reference | Endpoint | Description | |---|---| | `POST /events`, `GET /events`, `GET`, `PATCH`, `DELETE /events/{id or name}` | Event definitions. `PATCH` changes the schema. | | `POST /events/send` | Send an event for a contact. | | `POST /automations`, `GET /automations`, `GET`, `PATCH`, `DELETE /automations/{id}` | Automations. | | `POST /automations/{id}/stop`, `POST /automations/{id}/duplicate` | Stop, duplicate. | | `GET /automations/{id}/runs`, `GET /automations/{id}/runs/{run_id}` | Runs (`status`, `limit`, `after`) and one run with its steps. | ## Next steps - [Templates](https://hatang.dev/docs/guides/email/templates.md) - [Contacts, segments and topics](https://hatang.dev/docs/guides/email/audiences.md) - [Metrics and logs](https://hatang.dev/docs/guides/email/metrics-and-logs.md) Source: https://hatang.dev/docs/guides/email/automations --- # Broadcasts A broadcast is a marketing email, such as a newsletter, a sale or a product update, sent to every contact of a segment. Ha Tang turns it into one email per contact, fills in each contact's name and properties, skips people who unsubscribed and adds an unsubscribe link and a one-click `List-Unsubscribe` header. ## Before you start - A verified domain for the `From` address. See [Domains](https://hatang.dev/docs/guides/email/domains.md). - A segment with contacts, and topics if recipients should be able to pick what they receive. See [Contacts, segments and topics](https://hatang.dev/docs/guides/email/audiences.md). - For the API, a key with `email:write`. ## How a broadcast picks its recipients A contact receives the broadcast when all of these are true: 1. The contact is in the broadcast's segment. 2. The contact has not unsubscribed from everything. 3. If the broadcast has a topic: the contact is subscribed to it. For an `opt_in` topic, that means the contact has not left it; for an `opt_out` topic, the contact must have joined it. 4. The address is not on the suppression list (hard bounce, complaint, manual block). Suppressed addresses are skipped and reported as bounced. See [Suppressions and bounces](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md). ## Compose In the dashboard, open **Email → Broadcasts → New broadcast**: - **Segment**: who receives it. **Topic**: optional; **No topic (everyone subscribed)** sends to every subscribed contact of the segment. - **Internal name**, **From** and **Subject**. - **Start from a template** copies a published template's HTML, text, sender and subject into the broadcast. - The HTML body and an optional **Plain text** version, with **Preview**. Save it with **Create draft**. You can change a broadcast while it is a draft or scheduled. ### Merge tags Merge tags are filled for each contact: | Tag | Value | |---|---| | `{{{FIRST_NAME}}}`, `{{{LAST_NAME}}}`, `{{{EMAIL}}}` | The contact's name and address. | | `{{{contact.first_name}}}`, `{{{contact.plan}}}`… | Contact fields and custom properties. | | `{{{UNSUBSCRIBE_URL}}}` | The contact's preferences page. `{{{RESEND_UNSUBSCRIBE_URL}}}` is accepted too. | Add a default after `|`: `Hi {{{FIRST_NAME|there}}}`. A property without a value uses its fallback. A tag with no value at all becomes empty; it does not stop the send. Values are HTML-escaped in the HTML body. ```html

Hi {{{FIRST_NAME|there}}},

Your {{{contact.plan|free}}} plan now includes 10 GB of storage.

Unsubscribe or choose what you receive

``` ### Create with the API **cURL** ```bash curl -X POST https://api.hatang.dev/broadcasts \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "October newsletter", "segment_id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10", "topic_id": "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5", "from": "Acme ", "subject": "What is new in October, {{{FIRST_NAME|friend}}}", "html": "

Hi {{{FIRST_NAME|there}}}, …

Unsubscribe

" }' # { "object": "broadcast", "id": "bc_…" } ``` **Node.js** ```ts const { data, error } = await hatang.broadcasts.create({ name: "October newsletter", segment_id: "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10", topic_id: "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5", from: "Acme ", subject: "What is new in October, {{{FIRST_NAME|friend}}}", html: '

Hi {{{FIRST_NAME|there}}}, …

Unsubscribe

', }); ``` **Python** ```python res = requests.post( "https://api.hatang.dev/broadcasts", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={ "name": "October newsletter", "segment_id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10", "from": "Acme ", "subject": "What is new in October", "html": '

Hi {{{FIRST_NAME|there}}}, …

Unsubscribe

', }, ) broadcast_id = res.json()["id"] ``` **PHP** ```php $res = $client->post('/broadcasts', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'name' => 'October newsletter', 'segment_id' => 'aud_0199e6c17a2b7f009c1e3f6a2b1d9e10', 'from' => 'Acme ', 'subject' => 'What is new in October', 'html' => '

Hi {{{FIRST_NAME|there}}}, …

Unsubscribe

', ], ]); $broadcastId = json_decode($res->getBody(), true)['id']; ``` | Field | Description | |---|---| | `segment_id` | Required. The segment to send to. `audience_id` is the older name and still works. | | `topic_id` | Optional. Only contacts subscribed to this topic. | | `template_id` | Optional. Copy `html`, `text`, `from`, `subject` and `reply_to` from this published template. Fields you send win. | | `from` | Required unless the template sets it. | | `subject` | Required unless the template sets it. Up to 998 characters. | | `reply_to` | One address or a list. | | `html`, `text` | The body. At least one is needed before you send. | | `name` | Internal name, up to 200 characters. Defaults to the subject. | The template is copied when the broadcast is created; publishing a new template version later does not change it. ## Send or schedule In the editor, the **Send** box takes `now`, an ISO date or a phrase such as `in 2 hours`. Save your changes before you click **Send broadcast**. With the API: **cURL** ```bash # Now curl -X POST https://api.hatang.dev/broadcasts/$BROADCAST_ID/send -H "Authorization: Bearer $HATANG_API_KEY" # Later curl -X POST https://api.hatang.dev/broadcasts/$BROADCAST_ID/send \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "scheduled_at": "2026-10-20T09:00:00+07:00" }' # { "object": "broadcast", "id": "bc_…", "status": "scheduled" } ``` **Node.js** ```ts await hatang.broadcasts.send(broadcastId); // now await hatang.broadcasts.send(broadcastId, { scheduledAt: "in 2 hours" }); // later ``` **Python** ```python headers = {"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"} requests.post(f"https://api.hatang.dev/broadcasts/{broadcast_id}/send", headers=headers, json={"scheduled_at": "in 2 hours"}) ``` **PHP** ```php $client->post("/broadcasts/$broadcastId/send", [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['scheduled_at' => 'in 2 hours'], ]); ``` `scheduled_at` takes the same values as for single emails: an ISO 8601 date up to 30 days ahead, or `in N minutes|hours|days`. Sending a scheduled broadcast again replaces its schedule. `POST /broadcasts/{id}/cancel` stops a broadcast that is scheduled or still queued. A broadcast that is sending cannot be canceled or deleted. | Status | Meaning | |---|---| | `draft` | Not sent yet. You can edit it. | | `scheduled` | Waiting for its time. You can still edit or cancel it. | | `queued` | About to start. | | `sending` | Emails are being created for each contact. | | `sent` | Every recipient has been handled. | | `canceled` | Canceled before it started. | `GET /broadcasts/{id}` shows `total` (contacts selected) and `sent_count` (emails created). Each email of a broadcast is a normal email with the tag `broadcast_id`, so you can follow it under **Email → Emails** (filter **Broadcast**), in `GET /emails?source=broadcast` and through the usual webhooks. If sending is interrupted, Ha Tang picks up where it stopped and never sends a contact the same broadcast twice. > **Caution: Quota** > > Every email of a broadcast counts toward your daily quota. A broadcast to more contacts than the quota you have left stops sending when the quota runs out; the remaining contacts are marked as failed. Check **Email settings** before a large send. ## Unsubscribe {#unsubscribe} Every broadcast email carries two ways out: - The `{{{UNSUBSCRIBE_URL}}}` link in your content opens the hosted preferences page, where the contact picks topics or unsubscribes from everything. Opening it changes nothing by itself. - A `List-Unsubscribe` header with `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058), so Gmail, Apple Mail and others show their own unsubscribe button. One click removes the contact from the broadcast's topic, or from everything when the broadcast has no topic. Ha Tang adds the header for you. Put the link in your content as well: some mail clients do not show the button, and the law in many countries requires a visible way to unsubscribe. When a contact unsubscribes from everything, they stop receiving broadcasts and automation emails, and their address is added to the suppression list with the reason `unsubscribe`, which also blocks other emails to it. See [Suppressions and bounces](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md). Brand the preferences page under **Email settings → Unsubscribe page**: title, description, logo and colors. ## API reference | Endpoint | Description | |---|---| | `POST /broadcasts` | Create a draft. Returns `201` with the id. | | `GET /broadcasts`, `GET /broadcasts/{id}` | List, or get one with `status`, `total`, `sent_count`, `scheduled_at` and `sent_at`. | | `PATCH /broadcasts/{id}` | Change a draft or scheduled broadcast. | | `POST /broadcasts/{id}/send` | Send now or schedule (`scheduled_at`). | | `POST /broadcasts/{id}/cancel` | Cancel a scheduled or queued broadcast. | | `DELETE /broadcasts/{id}` | Delete it, unless it is sending. | ## Next steps - [Contacts, segments and topics](https://hatang.dev/docs/guides/email/audiences.md) - [Templates](https://hatang.dev/docs/guides/email/templates.md) - [Deliverability](https://hatang.dev/docs/guides/email/deliverability.md) - [Metrics and logs](https://hatang.dev/docs/guides/email/metrics-and-logs.md) Source: https://hatang.dev/docs/guides/email/broadcasts --- # Deliverability Deliverability is whether your email reaches the inbox instead of the spam folder. Mailbox providers such as Gmail, Yahoo and Outlook judge three things: whether the mail is really from you (authentication), whether people want it (engagement, bounces and complaints) and how you have behaved over time (reputation). This page explains how Ha Tang's setup covers authentication and what you control for the rest. ## Authentication and alignment DMARC passes when SPF or DKIM passes **and** the domain it checked matches (aligns with) the domain in your `From` address. Ha Tang sets up both so they align: | Check | What Ha Tang does | What you publish | Aligns with `From: you@example.com` | |---|---|---|---| | DKIM | Signs every message with your domain's own 2048-bit key (`d=example.com`, selector `rs…`). | The DKIM TXT record. | Yes, the signing domain is your domain. | | SPF | Uses `bounce+…@send.example.com` as the envelope sender (`Return-Path`). | The SPF TXT and MX records on `send.example.com`. | Yes under relaxed alignment, because `send.example.com` is a subdomain of `example.com`. | | DMARC | Proposes `v=DMARC1; p=none;` and checks that a DMARC record exists. | The `_dmarc` TXT record. | Uses the two results above. | Because the SPF and MX records live on the `send.` subdomain, they do not touch the SPF record of your root domain, and you do not need to add Ha Tang to it. See [Domains](https://hatang.dev/docs/guides/email/domains.md) for the exact records. ### Tighten DMARC step by step `p=none` only monitors. Once your DMARC reports show that all your real mail passes, move to a stricter policy so others cannot spoof your domain: ```text _dmarc.example.com TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com" _dmarc.example.com TXT "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc@example.com" _dmarc.example.com TXT "v=DMARC1; p=reject; rua=mailto:dmarc@example.com" ``` Publish only one of these at a time. `rua` sends you daily aggregate reports; read them with a DMARC report tool before each step. Before you move to `quarantine` or `reject`, check every other service that sends as your domain (your office mailboxes, your CRM, your invoicing tool) and make sure each one passes too. ### What large mailbox providers require Gmail and Yahoo require bulk senders to authenticate with SPF and DKIM, publish DMARC, offer one-click unsubscribe in marketing mail and keep spam complaints low. With Ha Tang: - SPF, DKIM and DMARC come from your domain setup above. - Broadcasts and automation emails get `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers automatically. See [Broadcasts](https://hatang.dev/docs/guides/email/broadcasts.md#unsubscribe). - Complaints are tracked in [Metrics](https://hatang.dev/docs/guides/email/metrics-and-logs.md), and every complaint suppresses the address. Read Google's [Email sender guidelines](https://support.google.com/a/answer/81126) for the current rules. ## Separate your streams Send different kinds of mail from different domains so that one cannot hurt the other: | Stream | Example domain | |---|---| | Account and transactional mail (receipts, password resets) | `example.com` or `notify.example.com` | | Marketing (broadcasts, newsletters) | `updates.example.com` | Each domain you add has its own DKIM key and return path, and Metrics can be filtered per domain. Restrict API keys to a single domain if different systems send different streams. ## Warm up a new domain A domain or subdomain that has never sent email has no reputation yet. Mailbox providers watch the first weeks closely. 1. Start with your most engaged recipients: people who signed up recently or opened your recent emails. 2. Start with a small volume and increase it gradually over a few weeks, for example by doubling it every few days as long as bounces and complaints stay low. 3. Keep a steady rhythm. Long silences followed by a large burst look like spam. 4. Watch the bounce and complaint rates after each step, and slow down if they rise. A daily quota per business is a safety net against sudden spikes. You can see it under **Email settings**. ## Keep your lists clean - **Collect consent.** Only send marketing to people who asked for it. A confirmation email at signup (double opt-in) keeps typos and fake addresses off your list. Never buy or rent lists. - **Let bounces and complaints suppress.** Ha Tang does it automatically: see [Suppressions and bounces](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md). Do not remove those entries without a good reason. - **Use topics.** People who can pick fewer emails unsubscribe less and complain less. See [Contacts, segments and topics](https://hatang.dev/docs/guides/email/audiences.md). - **Remove inactive contacts.** Contacts who have not opened or clicked for months lower your engagement. Stop sending to them or ask them once whether they want to stay. - **Make unsubscribing easy.** Put the `{{{UNSUBSCRIBE_URL}}}` link where people can find it. A recipient who cannot find it clicks "Report spam" instead. ## Write mail that looks like you - Use a recognizable `From` name and a stable address, such as `Acme `. - Send a plain-text part with your HTML. Templates generate it for you; with `POST /emails`, pass `text`. - Link to your own domain. With click tracking on, use a [tracking subdomain](https://hatang.dev/docs/guides/email/tracking.md) so tracked links also use your domain. - Avoid public link shorteners and attachments in marketing mail. - Make `Reply-To` an address someone reads. ## Monitor | Signal | Where | Watch for | |---|---|---| | Bounce rate | **Email → Metrics** | The dashboard highlights it above **4%**. A sudden rise usually means an old or bought list. | | Complaint rate | **Email → Metrics** | The dashboard highlights it above **0.08%**. Check how people joined the list and how easy it is to unsubscribe. | | `email.bounced` with `Policy` or `ContentRejected` | Webhooks, **Email → Emails** | Receiving servers are blocking your content or your reputation. | | DMARC aggregate reports | The `rua` address of your DMARC record | Mail that fails SPF or DKIM, or services sending as you that you do not know about. | | Domain reputation at Gmail | [Google Postmaster Tools](https://postmaster.google.com/) | Spam rate and reputation of your domain as Gmail sees it. | ## Checklist - [ ] The domain is **Verified**, with DKIM, return-path MX and SPF. - [ ] A DMARC record is published. - [ ] Marketing and transactional mail come from different domains or subdomains. - [ ] Every marketing email has a visible unsubscribe link. - [ ] Your HTML emails also have a plain-text part. - [ ] Bounce and complaint rates are checked after each large send. ## Next steps - [Domains](https://hatang.dev/docs/guides/email/domains.md) - [Suppressions and bounces](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md) - [Metrics and logs](https://hatang.dev/docs/guides/email/metrics-and-logs.md) Source: https://hatang.dev/docs/guides/email/deliverability --- # Domains You can only send from a domain you have added to Ha Tang and verified. Each domain gets its own DKIM key and its own return-path subdomain, so receiving mailboxes can check that the mail really comes from you. This page covers adding a domain, the DNS records, verification, one-click setup with Domain Connect and what to do when a record does not verify. ## Before you start - Access to the DNS settings of the domain (Cloudflare, your registrar or your DNS host). - For the API: a key with the `email:write` scope. Domain endpoints refuse keys without it. ## Pick the domain to send from {#pick-the-domain-to-send-from} The domain part of your `From` address must match a verified domain **exactly**. To send from `hello@updates.example.com`, add `updates.example.com`; adding `example.com` is not enough, and the reverse is also true. > **Tip: Use a subdomain for marketing** > > A subdomain such as `updates.example.com` or `mail.example.com` keeps its sending reputation apart from your root domain. If a campaign goes badly, the mail your staff send from `example.com` is not affected. Many teams use one subdomain for transactional mail and another for broadcasts. ## Add a domain **cURL** ```bash curl -X POST https://api.hatang.dev/domains \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "example.com" }' ``` **Node.js** ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data: domain, error } = await hatang.domains.create({ name: "example.com" }); if (error) throw new Error(error.message); console.table(domain.records); ``` **Python** ```python import os import requests res = requests.post( "https://api.hatang.dev/domains", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={"name": "example.com"}, ) res.raise_for_status() for record in res.json()["records"]: print(record["type"], record["name"], record["value"]) ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->post('/domains', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['name' => 'example.com'], ]); $domain = json_decode($res->getBody(), true); ``` In the dashboard, open **Email → Domains**, type the domain in **Domain** and click **Add domain**. **Advanced options** holds the same settings as the API fields below. | Field | Default | Description | |---|---|---| | `name` | required | The domain or subdomain, for example `example.com` or `updates.example.com`. Stored in lower case. | | `custom_return_path` | `send` | Subdomain label for the bounce address and SPF, so `send` gives `send.example.com`. Letters, digits and hyphens, up to 63 characters. Dashboard: **Custom Return-Path**. | | `tracking_subdomain` | none | Optional label for branded tracking links, for example `links` gives `links.example.com`. Must differ from the return-path label. Dashboard: **Tracking Subdomain**. See [Open and click tracking](https://hatang.dev/docs/guides/email/tracking.md). | | `open_tracking` | `false` | Add an open-tracking pixel to HTML emails from this domain. | | `click_tracking` | `false` | Rewrite links in HTML emails from this domain to record clicks. | | `region` | platform region | Accepted for Resend compatibility and stored. Sending always uses the platform's Amazon SES region. | The response (`201`) is the domain with the records to publish: ```json { "object": "domain", "id": "dom_0199e6c17a2b7f009c1e3f6a2b1d9e10", "name": "example.com", "status": "not_started", "created_at": "2026-10-14T09:12:03.000Z", "region": "us-west-2", "dkim_selector": "rs7hq2kx9p", "open_tracking": false, "click_tracking": false, "tracking_subdomain": null, "tracking_domain_verified": false, "custom_return_path": "send", "ses_status": "pending", "tls": "opportunistic", "capabilities": { "sending": "enabled", "receiving": "disabled" }, "records": [ { "record": "MX", "name": "send.example.com", "type": "MX", "ttl": "Auto", "status": "not_started", "value": "feedback-smtp.us-west-2.amazonses.com", "priority": 10, "required": true }, { "record": "SPF", "name": "send.example.com", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=spf1 include:amazonses.com ~all", "required": true }, { "record": "DKIM", "name": "rs7hq2kx9p._domainkey.example.com", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh…", "required": true }, { "record": "DMARC", "name": "_dmarc.example.com", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=DMARC1; p=none;", "required": false } ] } ``` Adding the same domain twice returns `409`. ## The DNS records Always copy names and values from the dashboard or from the `records` array. The values below are examples. | Record | Type | Name | Value | Required | |---|---|---|---|---| | DKIM | TXT | `._domainkey.example.com` | `v=DKIM1; k=rsa; p=` | Yes | | Return-path MX | MX, priority 10 | `send.example.com` | `feedback-smtp..amazonses.com` | Yes | | Return-path SPF | TXT | `send.example.com` | `v=spf1 include:amazonses.com ~all` | Yes | | DMARC | TXT | `_dmarc.example.com` | `v=DMARC1; p=none;` | No, recommended | | Tracking | CNAME | `links.example.com` | The tracking host shown in the dashboard | Only with a tracking subdomain | | Receiving | MX, priority 10 | `example.com` | The inbound host shown in the dashboard | Only with receiving turned on | ### DKIM Ha Tang creates a 2048-bit RSA key for each domain. The public half goes in a TXT record under a selector that starts with `rs` followed by eight random characters (the `dkim_selector` field). Every message is signed with the private half for your domain, which is what lets DMARC pass on DKIM. A 2048-bit key is longer than 255 characters. Most DNS hosts split it into several strings for you; paste the whole value as one record. Ha Tang ignores whitespace when it compares the key. ### Return path: MX and SPF on `send.` The bounce address (the envelope sender, or `Return-Path`) of every message is on your return-path subdomain, for example `bounce+…@send.example.com`. Two records on that subdomain make it work: - The **MX** record sends bounces and complaints back to Amazon SES, which reports them to Ha Tang. - The **SPF** TXT record authorizes Amazon SES to send for that subdomain. Because the return path is a subdomain of your domain, SPF aligns with your `From` domain under DMARC's relaxed alignment. The records go on the subdomain, not on the root, so they never collide with the SPF or MX records your root domain already has. If the subdomain already has an SPF record, Ha Tang accepts any `v=spf1` record that contains `include:amazonses.com`. ### DMARC DMARC tells receiving mailboxes what to do with mail that fails SPF and DKIM alignment. Ha Tang proposes `v=DMARC1; p=none;`, which only asks for monitoring. Any record that starts with `v=DMARC1` counts as present, so keep your existing policy if you have one. DMARC does not block verification. Gmail and Yahoo expect bulk senders to publish one, so add it. See [Deliverability](https://hatang.dev/docs/guides/email/deliverability.md). ### Tracking and receiving These two records appear only when you use the feature: the tracking CNAME when the domain has a tracking subdomain, and the receiving MX when you turn on receiving. Neither blocks sending. See [Open and click tracking](https://hatang.dev/docs/guides/email/tracking.md) and [Receive email](https://hatang.dev/docs/guides/email/receiving.md). > **Caution: Names at your DNS host** > > Many DNS hosts add the zone to the name you type. If you type `send.example.com` in the **Name** field for the zone `example.com`, you may end up with `send.example.com.example.com`. Type only the part before your domain (`send`, `rs7hq2kx9p._domainkey`, `_dmarc`) when your host works that way. ## Verify the domain When the records are published, start verification: - Dashboard: open the domain and click **Verify DNS records**. While the domain is pending, the page checks again and refreshes by itself. - API: `POST /domains/{id}/verify`, or `hatang.domains.verify(id)` in the Node.js SDK. ```bash curl -X POST https://api.hatang.dev/domains/dom_0199e6c17a2b7f009c1e3f6a2b1d9e10/verify \ -H "Authorization: Bearer $HATANG_API_KEY" ``` ```json { "object": "domain", "id": "dom_0199e6c17a2b7f009c1e3f6a2b1d9e10", "status": "pending" } ``` Ha Tang checks every record right away, then keeps checking in the background until the domain is verified. DNS changes can take up to an hour to reach everyone. A domain is **Verified** when every required record (DKIM, return-path MX and SPF) matches **and** Amazon SES has confirmed the DKIM signature. Optional records never hold verification back. ### Domain statuses | Status | Dashboard | Meaning | |---|---|---| | `not_started` | Not started | The domain was added; verification has not been requested yet. | | `pending` | Pending | Ha Tang is checking. Some required records do not match yet, or Amazon SES has not confirmed DKIM yet. | | `verified` | Verified | You can send from the domain. | | `temporary_failure` | Temporary failure | A DNS lookup failed for a reason other than "not found", for example a timeout. Ha Tang tries again. | | `failed` | Failed | Verification failed, for example because Amazon SES rejected the DKIM setup. Fix the records and verify again. | Each record also has its own `status` (`not_started`, `pending`, `verified` or `failed`) and, when it fails, a `last_error` that says what Ha Tang found instead, such as `found: nothing` or the value that is there now. Ha Tang sends the `domain.created`, `domain.updated` and `domain.deleted` webhooks, with the domain as `GET /domains/{id}` shows it. A status change fires `domain.updated`. ## One-click setup with Domain Connect If your DNS host supports [Domain Connect](https://www.domainconnect.org/), the domain page shows a **DNS Records** panel with **Auto configure with** your host's name. Click it, sign in at your DNS host and approve the change. The host adds the records, sends you back to Ha Tang and verification starts right away. **Manual setup** jumps to the table of records instead. Domain Connect adds: - the return-path MX record and the SPF rule (`include:amazonses.com`, merged into an existing SPF record on that subdomain), - the DKIM record, - the DMARC record (`v=DMARC1; p=none;`), - the tracking CNAME, when the domain has a tracking subdomain. > **Note** > > Review the changes your DNS host shows before you approve them, especially if the domain already has a DMARC record you want to keep. When the DNS host does not support Domain Connect but the domain is on Cloudflare, the domain page links straight to the zone's DNS records instead. From the API: ```bash # Can this domain be set up in one click? curl https://api.hatang.dev/domains/$DOMAIN_ID/domain-connect -H "Authorization: Bearer $HATANG_API_KEY" # { "object": "domain_connect", "available": true, "reason": null, "dns_host": "Cloudflare", "zone": "example.com" } # Get the signed URL to send the user to curl -X POST https://api.hatang.dev/domains/$DOMAIN_ID/domain-connect \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" -d '{}' # { "object": "domain_connect", "id": "dom_…", "url": "https://…" } ``` When `available` is `false`, `reason` is `no_domain_connect` (the DNS host does not support it), `template_not_supported` (it supports Domain Connect but not Ha Tang's template yet) or `not_configured`. `POST` takes an optional `redirect_uri`; without it, the user comes back to the domain page in the dashboard. `GET /domains/{id}/dns-provider` tells you which DNS host serves the domain: ```json { "object": "dns_provider", "zone": "example.com", "provider": "cloudflare", "nameservers": ["ada.ns.cloudflare.com", "bob.ns.cloudflare.com"], "cloudflare_url": "https://dash.cloudflare.com/…" } ``` ## Custom return path The return-path label is `send` unless you choose another one when you add the domain. Change it only if `send.example.com` is already in use for something else, for example by another email provider's bounce records. It cannot be changed afterwards: delete the domain and add it again with the new label. ## Change or delete a domain `PATCH /domains/{id}` changes the settings that can change after creation: ```bash curl -X PATCH https://api.hatang.dev/domains/$DOMAIN_ID \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "open_tracking": true, "click_tracking": true, "tracking_subdomain": "links" }' ``` | Field | Effect | |---|---| | `open_tracking`, `click_tracking` | Turn tracking on or off for emails sent from now on. | | `tracking_subdomain` | Set, change or remove (`null`) the branded tracking host. A new CNAME record appears to publish. | | `capabilities.receiving` | `"enabled"` or `"disabled"`. See [Receive email](https://hatang.dev/docs/guides/email/receiving.md). | `DELETE /domains/{id}` removes the domain and its Amazon SES identity. Emails still queued for that domain bounce with "Sending domain no longer exists". ## Troubleshooting | Symptom | Cause and fix | |---|---| | A record shows `found: nothing` | The record is missing or under the wrong name. Check for the zone being added twice (see the caution above), then wait a few minutes for DNS to update. | | DKIM fails but the record is there | The value was cut or changed. Paste the full `v=DKIM1; k=rsa; p=…` value again. Some hosts need the long value in quotes or split into several strings. | | SPF on `send.` fails | The subdomain has an SPF record without `include:amazonses.com`, or has two SPF records. Keep one `v=spf1` record that includes `include:amazonses.com`. | | All records verify but the status stays `pending` | Amazon SES has not confirmed DKIM yet. This usually follows the DNS within minutes; click **Re-check DNS** later. | | The return-path MX conflicts with another record | `send.example.com` already has a CNAME. A name with a CNAME cannot have other records. Remove the CNAME or add the domain again with another **Custom Return-Path**. | | `403` "The example.com domain is not verified" when sending | The `From` domain is not added, not verified, or is a different subdomain. See [Pick the domain to send from](#pick-the-domain-to-send-from). | | `401 restricted_api_key` when sending | The API key is limited to another domain. Use a key for this domain or an unrestricted key. | ## Next steps - [Send email](https://hatang.dev/docs/guides/email/sending.md) - [Deliverability](https://hatang.dev/docs/guides/email/deliverability.md) - [Open and click tracking](https://hatang.dev/docs/guides/email/tracking.md) - [Receive email](https://hatang.dev/docs/guides/email/receiving.md) Source: https://hatang.dev/docs/guides/email/domains --- # Email groups An email group is an address on one of your receiving domains, such as `team@example.com`, whose mail is forwarded to a list of members. It works like a Google Workspace group: anyone can write to the address, and every member gets the message. ## How a forwarded message looks Each member gets their own copy, so members do not see each other's addresses: - **From:** `"Alice via Sales" `. Mail can only be sent from your own verified domain, so the copy comes from the group address with the sender's name in front. - **Reply-To:** the original sender. Pressing Reply answers them directly. - Subject, text, HTML and attachments are the original's. The copy carries `X-Hatang-Forwarded-By` and `List-Id` headers. The original stays in **Email → Receiving** and still fires `email.received`. Each copy is an ordinary send with source `forward`: it shows up in **Emails**, gets delivery events and **counts toward your daily send quota**. Not forwarded: mail marked as spam, automated mail (auto-replies, out-of-office, bounces, mailing lists), mail that already went through a group, and more than 30 messages per sender per group per hour. ## Before you start - A verified domain with **Receiving** on. See [Receive email](https://hatang.dev/docs/guides/email/receiving.md). - An API key with `email:write` to manage groups (`email:read` to list them). ## Create a group In the dashboard, open **Email → Email groups**, pick the address and the domain, and list the members, one per line. Or use the API: **cURL** ```bash curl -X POST https://api.hatang.dev/email-groups \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "domain_id": "dom_...", "local_part": "team", "name": "Sales", "members": ["an@example.com", "binh@example.com"] }' ``` **Node.js** ```ts const group = await hatang.emailGroups.create({ domain_id: "dom_...", local_part: "team", name: "Sales", members: ["an@example.com", "binh@example.com"], }); ``` **Python** ```python requests.post( "https://api.hatang.dev/email-groups", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={"domain_id": "dom_...", "local_part": "team", "name": "Sales", "members": ["an@example.com", "binh@example.com"]}, ) ``` A group has up to 100 members. A member cannot be the group itself or another group, so groups never forward to each other in a loop. ## Change or delete a group `PATCH /email-groups/{id}` changes the name, description or members (send the whole member list). The address does not change; delete the group and create another instead. `DELETE /email-groups/{id}` removes it: mail to the address is still received and kept in Receiving, just no longer forwarded. See the [API reference](https://hatang.dev/docs/reference/api.md) for every field. Source: https://hatang.dev/docs/guides/email/groups --- # Metrics and logs Metrics show how your email performs over time: how much you sent, how much arrived, and how many people opened, clicked, bounced or complained. Logs show every API call your keys made, with what was sent and what came back, so you can debug an integration without adding logging to your own code. ## Metrics in the dashboard **Email → Metrics** shows the volume per day (sent, delivered, opened, clicked) and the delivery, open, click, bounce and complaint rates. Pick the last 7, 14 or 30 days and one domain or all of them. Days follow Vietnam time. - The bounce rate chart marks **4%** and the complaint rate chart marks **0.08%**. Above these lines, Gmail and Yahoo start to throttle your mail or send it to spam. - Open and click rates are over delivered mail and count each person once. - Opens are an estimate: Apple Mail Privacy Protection and other image proxies can make them look higher. See [Open and click tracking](https://hatang.dev/docs/guides/email/tracking.md). ## Metrics API `GET /emails/metrics` returns the same numbers, in the shape of Resend's metrics endpoint. A key with `email:read` (or `email:send` / `email:write`) is enough. **cURL** ```bash curl "https://api.hatang.dev/emails/metrics?start_date=2026-10-01&end_date=2026-10-14&timezone=Asia/Ho_Chi_Minh&dimensions=period,domain&metrics=sent,delivered,bounce_rate" \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts const { data } = await hatang.emails.metrics({ start_date: "2026-10-01", end_date: "2026-10-14", timezone: "Asia/Ho_Chi_Minh", dimensions: ["period", "domain"], metrics: ["sent", "delivered", "bounce_rate"], }); ``` **Python** ```python res = requests.get( "https://api.hatang.dev/emails/metrics", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, params={"start_date": "2026-10-01", "end_date": "2026-10-14", "timezone": "Asia/Ho_Chi_Minh", "metrics": "sent,delivered,bounce_rate"}, ) ``` **PHP** ```php $res = $client->get('/emails/metrics', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'query' => ['start_date' => '2026-10-01', 'end_date' => '2026-10-14', 'timezone' => 'Asia/Ho_Chi_Minh', 'metrics' => 'sent,delivered,bounce_rate'], ]); ``` | Parameter | Description | |---|---| | `start_date`, `end_date` | A date (`2026-10-01`, read as midnight UTC) or an ISO date and time. A date-only `end_date` includes that whole day (until 23:59:59 UTC). The range is at most 31 days. Without them: the last 7 days. | | `timezone` | IANA time zone used to cut periods, for example `Asia/Ho_Chi_Minh`. Default `UTC`. | | `granularity` | `hourly`, `daily` (default), `weekly` or `monthly`. | | `metrics` | Comma-separated list of the metrics to return. Default: all of them. | | `dimensions` | `period` (default), or `period,domain` to split each period per domain. | | `domain_id` | Only these domains, comma-separated. | ```json { "object": "metrics", "start_date": "2026-10-01T00:00:00.000Z", "end_date": "2026-10-14T23:59:59.999Z", "timezone": "Asia/Ho_Chi_Minh", "granularity": "daily", "metrics": ["sent", "delivered", "bounce_rate"], "dimensions": ["period"], "totals": { "sent": 1840, "delivered": 1812, "bounce_rate": 0.0098 }, "data": [ { "period": "2026-10-01T00:00:00", "sent": 120, "delivered": 119, "bounce_rate": 0.0083 }, { "period": "2026-10-02T00:00:00", "sent": 134, "delivered": 131, "bounce_rate": 0.0149 } ] } ``` `totals` covers the whole range; `data` has one row per period (and per domain, with `domain_id` and `domain_name`). Rates are fractions rounded to four decimals, so `0.0098` is 0.98%. ### Counts | Metric | Counts | |---|---| | `sent` | Recipients Ha Tang sent to, without suppressed and canceled ones. | | `delivered` | Delivery events. | | `delivery_delayed` | Delay events. | | `failed` | Failure events. | | `suppressed` | Recipients skipped because they were suppressed. | | `bounced` | Bounce events, including those of suppressed recipients. | | `opened`, `unique_opened` | Opens, and recipients who opened at least once. | | `clicked`, `unique_clicked` | Clicks, and recipients who clicked at least once. | | `complained` | Spam complaints. | | `unsubscribed` | Contacts who unsubscribed from everything. Not split per domain, so it is left out when you split or filter by domain. | | `received` | Messages received on your domains. | ### Rates | Rate | Formula | |---|---| | `delivery_rate` | `delivered / sent` | | `bounce_rate` | `bounced / sent` | | `open_rate` | `unique_opened / delivered` | | `click_rate` | `unique_clicked / delivered` | | `complaint_rate` | `complained / delivered` | | `unsubscribe_rate` | `unsubscribed / delivered` | Sends are counted in the period the email was created; events in the period they happened. ## Logs **Email → Logs** records every API call made with your business's keys: endpoint, method, status, duration, API key, the user agent (which shows the SDK and its version) and the request and response bodies. Filter by status, method, API key or path. A failed call has a **Copy for AI** button that puts the request, the response and a link to the docs into one block you can paste into an AI assistant. - Passwords, secrets, tokens and API keys in bodies are replaced with `[redacted]`. Attachment content is replaced with its size. - Each body is kept up to 16 KB. Logs are kept for 30 days. - Calls made by the dashboard itself are not in the log; only calls made with an API key are. ```bash curl "https://api.hatang.dev/logs?status=error&limit=20" -H "Authorization: Bearer $HATANG_API_KEY" curl "https://api.hatang.dev/logs/log_0199e6c17a2b7f009c1e3f6a2b1d9e10" -H "Authorization: Bearer $HATANG_API_KEY" ``` | `GET /logs` parameter | Description | |---|---| | `status` | `2xx`, `4xx`, `5xx` or `error` (any 4xx or 5xx). | | `method` | `GET`, `POST`, `PATCH`… | | `endpoint` | Part of the path, for example `/emails`. | | `api_key_id` | Only calls made with this key. | | `user_agent` | Part of the user agent, for example `hatang-sdk-node`. | | `since`, `until` | Time range. | | `limit`, `after`, `before` | Pagination, as for other lists. | A log entry looks like this; `GET /logs/{id}` adds `request_body`, `response_body` and `resource_ids` (the ids the response created or returned): ```json { "object": "log", "id": "log_…", "created_at": "2026-10-14T09:12:03.000Z", "endpoint": "/emails", "method": "POST", "response_status": 422, "duration_ms": 38, "user_agent": "hatang-sdk-node/1.4.0 (node/22.11.0)", "sdk": "hatang-sdk-node 1.4.0", "api_key_id": "key_…" } ``` ## Usage `GET /usage` shows what your business used against its limits: emails today against the daily quota (with the reset time), emails sent and received this month, and the number of domains, contacts, templates and automation runs this month. ```json { "object": "usage", "emails": { "daily": { "used": 312, "limit": 10000, "resets_at": "2026-10-15T00:00:00.000Z" }, "monthly": { "sent": 8840, "received": 112, "limit": null, "resets_at": "2026-11-01T00:00:00.000Z" } }, "domains": { "used": 2, "limit": null }, "contacts": { "used": 5120, "limit": null }, "templates": { "used": 14, "limit": null }, "automation_runs": { "monthly": 640, "limit": null } } ``` Reading logs and usage needs a key with a write scope on any product (for example `email:write`), or full access. ## Next steps - [Deliverability](https://hatang.dev/docs/guides/email/deliverability.md) - [Suppressions and bounces](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md) - [Errors](https://hatang.dev/docs/reference/errors.md) Source: https://hatang.dev/docs/guides/email/metrics-and-logs --- # Migrate from Resend Ha Tang Email follows Resend's API: the same endpoints, the same request and response bodies, the same error shape and the same webhook signatures. Most code moves over by changing the base URL and the API key. This page lists what is compatible, what differs, and the steps to switch without losing mail. ## What is compatible | Area | Compatible | |---|---| | Sending | `POST /emails` with `from`, `to`, `cc`, `bcc`, `reply_to`, `subject`, `html`, `text`, `headers`, `tags`, `attachments` (with `content`), `scheduled_at`, `template`; `POST /emails/batch`; `GET /emails/{id}`; `PATCH /emails/{id}` to reschedule; `POST /emails/{id}/cancel`. | | Idempotency | The `Idempotency-Key` header on `POST /emails`. | | Errors | The `{ "statusCode", "name", "message" }` body and names such as `validation_error`, `restricted_api_key`, `rate_limit_exceeded`, `invalid_idempotent_request`. | | Domains | `POST /domains` with `name` and `custom_return_path`, `GET`, `PATCH` (`open_tracking`, `click_tracking`, `tls`, `capabilities.receiving`), `DELETE`, `POST /domains/{id}/verify`. | | API keys | `POST /api-keys` with `permission` (`full_access` or `sending_access`) and `domain_id`. | | Webhooks | Endpoint management, the event names (`email.sent`, `email.delivered`, `email.bounced`, `email.complained`, `email.opened`, `email.clicked`, `email.received`, `contact.*`, `domain.*`…) and the payload shape. Requests are signed the Svix way, with `svix-id`, `svix-timestamp` and `svix-signature` headers (plus the same values as `webhook-id`, `webhook-timestamp`, `webhook-signature`), so your existing verification code keeps working with your new `whsec_…` secret. | | Contacts | `/contacts`, `/segments`, `/topics`, `/contact-properties` and the older `/audiences` API. | | Broadcasts | `POST /broadcasts` with `segment_id` (or `audience_id`), `topic_id`, `/send` with `scheduled_at`, `/cancel`. | | Templates | `/templates`, `/publish`, `/duplicate`, the `{{{VAR}}}` syntax with inline defaults, and `{{{RESEND_UNSUBSCRIBE_URL}}}`. | | Automations | `/events`, `/events/send`, `/automations` and their runs. | | Receiving | `/emails/receiving` and its attachments. | | Metrics | `GET /emails/metrics`. | ## What is different | Topic | Resend | Ha Tang | |---|---|---| | Base URL | `https://api.resend.com` | `https://api.hatang.dev` | | API keys | `re_…` | `ht_…`. Resend keys do not work. Keys have scopes (`email:send`, `email:write`…); `permission` is still accepted and mapped to scopes. | | Attachments by URL | `path` | Not supported yet: `422 invalid_attachment`. Send `content` as base64. | | Batch | | `Idempotency-Key` is not supported on `/emails/batch`. A batch is not all-or-nothing: items before a failing one are already sent. | | `react` | Rendered by the SDK | Same: the Node.js SDK renders it with `@react-email/render`; the API only takes `html` and `text`. | | Domain `region` | Picks the sending region | Accepted and stored; sending always uses the platform's Amazon SES region. | | DKIM record | `resend._domainkey` | `rs._domainkey`, a new key per domain. | | Rate limit | Per Resend's plan | 10 requests per second per business. | | SMTP | `smtp.resend.com`, several ports, user `resend` | `smtp.hatang.dev:5870`, STARTTLS only, user `hatang`. See [SMTP relay](https://hatang.dev/docs/guides/email/smtp.md). | | Suppression list | | Dashboard only (**Email → Audiences → Suppressions**), no API. | | Extras | | `topic_id` on `POST /emails`, `GET /emails/{id}/events` with per-recipient SMTP results, search and filters on `GET /emails`, `GET /logs` and `GET /usage`. | ## Switch step by step ### 1. Add your domains in Ha Tang {#1-add-your-domains-in-hatang} Add each sending domain under **Email → Domains** and publish the new records next to the Resend ones. The DKIM records use different selectors and do not conflict. > **Caution: Pick another return path while both are live** > > Resend and Ha Tang both use `send.` as the default return-path subdomain, each with its own Amazon SES feedback MX record. To avoid two sets of bounce records on the same name, choose a different **Custom Return-Path** in Ha Tang (for example `hsend`) when you add the domain, or remove Resend's records first if you stop Resend at the same moment. Verify the domains and wait for **Verified**. See [Domains](https://hatang.dev/docs/guides/email/domains.md). ### 2. Create API keys Create a key for each system that sends email, with only the scopes it needs (`email:send` for sending). Restrict a key to one domain if that system should only send from it. ### 3. Point your code at Ha Tang {#3-point-your-code-at-hatang} The simplest move is to switch to `@hatang/sdk`, which has the same method names and the same `{ data, error }` results: ```ts // Before import { Resend } from "resend"; const resend = new Resend(process.env.RESEND_API_KEY); await resend.emails.send({ from, to, subject, html }); // After import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); await hatang.emails.send({ from, to, subject, html }); ``` The same calls exist under `emails`, `batch`, `domains`, `apiKeys`, `webhooks`, `audiences`, `contacts`, `segments`, `topics`, `contactProperties`, `broadcasts`, `templates`, `events` and `automations`, and `emails.send(payload, { idempotencyKey })` takes the same option. See [Node.js SDK](https://hatang.dev/docs/guides/sdks/node.md). If you call the HTTP API directly, change the base URL to `https://api.hatang.dev` and the key to your `ht_…` key. Nothing else changes. ### 4. Recreate webhooks Create your webhook endpoints in Ha Tang (**Webhooks**, or `POST /webhooks`) with the same events, and put the new signing secret in your verification code. See [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md). ### 5. Move contacts Export your contacts from Resend and import the CSV under **Email → Audiences** (**Import CSV**), or create them with `POST /contacts`. Import people who unsubscribed with `unsubscribed` set to `true` so they stay unsubscribed. Recreate topics and segments first so you can assign contacts to them. ### 6. Switch SMTP users Apps that send through Resend's SMTP need the new host, port, username and password. See [SMTP relay](https://hatang.dev/docs/guides/email/smtp.md). ### 7. Watch the first days Follow **Email → Metrics** and **Email → Logs** for bounces, complaints and failed calls. A new DKIM key and a new return path have no history yet; ramp up large sends as described in [Deliverability](https://hatang.dev/docs/guides/email/deliverability.md). ## Coming from SendGrid or Postmark There is no compatibility layer for these APIs, but their send requests map field by field onto `POST /emails`. ### SendGrid (v3 Mail Send) | SendGrid | Ha Tang `POST /emails` | |---|---| | `from.email`, `from.name` | `from: "Name "` | | `personalizations[].to`, `.cc`, `.bcc` | `to`, `cc`, `bcc`. One personalization is one email; for several, use `POST /emails/batch`. | | `personalizations[].dynamic_template_data` | `template.variables` | | `template_id` | `template.id` (a Ha Tang template id or alias) | | `subject` | `subject` | | `content[]` with `text/plain` and `text/html` | `text` and `html` | | `reply_to` | `reply_to` | | `headers` | `headers` | | `categories`, `custom_args` | `tags` (`name` and `value` pairs) | | `attachments[].content`, `.filename`, `.type`, `.content_id` | `attachments[].content`, `.filename`, `.content_type`, `.content_id` | | `send_at` (Unix timestamp) | `scheduled_at` (ISO 8601) | ### Postmark (`/email`) | Postmark | Ha Tang `POST /emails` | |---|---| | `From` | `from` | | `To`, `Cc`, `Bcc` (comma-separated) | `to`, `cc`, `bcc` (arrays) | | `Subject` | `subject` | | `HtmlBody`, `TextBody` | `html`, `text` | | `ReplyTo` | `reply_to` | | `Headers` (`[{ "Name", "Value" }]`) | `headers` (`{ "Name": "Value" }`) | | `Tag`, `Metadata` | `tags` | | `Attachments[].Name`, `.Content`, `.ContentType`, `.ContentID` | `attachments[].filename`, `.content`, `.content_type`, `.content_id` | | `TemplateAlias`, `TemplateModel` (`/email/withTemplate`) | `template.id`, `template.variables` | | `TrackOpens`, `TrackLinks` | Per domain: `open_tracking`, `click_tracking`. See [Open and click tracking](https://hatang.dev/docs/guides/email/tracking.md). | Webhook events from either service map onto Ha Tang's `email.delivered`, `email.bounced`, `email.complained`, `email.opened` and `email.clicked`. Ha Tang webhooks are signed with Svix-style headers; see [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md). ## Next steps - [Domains](https://hatang.dev/docs/guides/email/domains.md) - [Send email](https://hatang.dev/docs/guides/email/sending.md) - [Node.js SDK](https://hatang.dev/docs/guides/sdks/node.md) Source: https://hatang.dev/docs/guides/email/migrate-from-resend --- # Ha Tang Email Ha Tang Email sends transactional and marketing email from your own domain. It speaks the same API as Resend, so existing Resend code works after you change the base URL and the API key, and it delivers through Amazon SES. This page shows how the pieces fit together and walks you through your first email. ## What you get | Feature | What it does | Guide | |---|---|---| | Sending domains | DKIM, SPF and return path for each domain, one-click DNS setup at supported DNS hosts | [Domains](https://hatang.dev/docs/guides/email/domains.md) | | REST API | `POST /emails` and `POST /emails/batch`, scheduling, idempotency, tags, attachments | [Send email](https://hatang.dev/docs/guides/email/sending.md) | | SMTP relay | `smtp.hatang.dev:5870` with STARTTLS, for frameworks and apps that only speak SMTP | [SMTP relay](https://hatang.dev/docs/guides/email/smtp.md) | | Templates | Reusable emails with variables, sent by id or alias | [Templates](https://hatang.dev/docs/guides/email/templates.md) | | Tracking | Open and click tracking, optionally on your own subdomain | [Open and click tracking](https://hatang.dev/docs/guides/email/tracking.md) | | Receiving | Mail sent to your domain becomes an `email.received` webhook | [Receive email](https://hatang.dev/docs/guides/email/receiving.md) | | Audiences | Contacts, segments, topics and custom properties | [Contacts, segments and topics](https://hatang.dev/docs/guides/email/audiences.md) | | Broadcasts | Marketing email to a segment, now or on a schedule, with a hosted preferences page | [Broadcasts](https://hatang.dev/docs/guides/email/broadcasts.md) | | Automations | Per-contact sequences started by events from your app | [Automations](https://hatang.dev/docs/guides/email/automations.md) | | Suppressions | Hard bounces, complaints and unsubscribes stop further sends | [Suppressions and bounces](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md) | | Metrics and logs | Delivery, open, click, bounce and complaint rates, plus a log of every API call | [Metrics and logs](https://hatang.dev/docs/guides/email/metrics-and-logs.md) | ## How a message travels ```mermaid flowchart LR A[Your app] -->|REST API or SMTP| B[Ha Tang] B -->|checks: verified domain, suppressions, daily quota| C[Send queue] C -->|DKIM-signed with your domain| D[Amazon SES] D --> E[Recipient's mailbox] D -->|delivery, bounce, complaint| B B -->|webhooks| A ``` 1. Your app calls `POST /emails` or hands a message to the SMTP relay. Both go through the same checks. 2. Ha Tang checks that the `From` domain is verified and belongs to your business, skips suppressed recipients and reserves one email from your daily quota. It answers with the email `id` right away. 3. A worker signs the message with your domain's DKIM key, sets the bounce address on your return-path subdomain (`send.` by default) and hands it to Amazon SES. 4. Amazon SES reports deliveries, bounces and complaints back to Ha Tang. Ha Tang updates the email's status, adds hard bounces and complaints to your suppression list and sends you webhooks. ## Before you start - A Ha Tang business with the **Email** module turned on. See [Businesses and modules](https://hatang.dev/help/getting-started/modules). - A domain you can add DNS records to. - An API key. See [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md). ## Send your first email ### 1. Add and verify a domain In the dashboard, open **Email → Domains**, add your domain (for example `example.com` or a subdomain such as `updates.example.com`), publish the DNS records it shows and click **Verify DNS records**. The domain must reach the **Verified** status before you can send from it. The details are in [Domains](https://hatang.dev/docs/guides/email/domains.md). ### 2. Create an API key Create a key with the `email:send` scope. Keep it on your server. Never put it in a browser or a mobile app. ### 3. Send **cURL** ```bash curl -X POST https://api.hatang.dev/emails \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "to": ["customer@example.net"], "subject": "Your order is confirmed", "html": "

Thanks for your order!

" }' ``` **Node.js** ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data, error } = await hatang.emails.send({ from: "Acme ", to: ["customer@example.net"], subject: "Your order is confirmed", html: "

Thanks for your order!

", }); if (error) throw new Error(`${error.name}: ${error.message}`); console.log(data.id); ``` **Python** ```python from hatang import Hatang # pip install hatang hatang = Hatang() # reads HATANG_API_KEY email = hatang.emails.send({ "from": "Acme ", "to": ["customer@example.net"], "subject": "Your order is confirmed", "html": "

Thanks for your order!

", }) print(email["id"]) ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->post('/emails', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'from' => 'Acme ', 'to' => ['customer@example.net'], 'subject' => 'Your order is confirmed', 'html' => '

Thanks for your order!

', ], ]); echo json_decode($res->getBody(), true)['id']; ``` The response holds the email's id: ```json { "id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10" } ``` > **Note** > > The Node.js SDK returns `{ data, error }` and never throws. Always pass `baseUrl: "https://api.hatang.dev"` (or set `HATANG_BASE_URL`), because the SDK's built-in default points at a local development server. ### 4. Follow the email - In the dashboard, **Email → Emails** lists every email with its status, recipients and events. - `GET /emails/{id}` returns the email and its `status`; `GET /emails/{id}/events` returns each event and each recipient's state. - For real-time updates, subscribe a webhook to `email.delivered`, `email.bounced` and the other events. See [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md). ## Dashboard map | Sidebar item | What you do there | |---|---| | **Emails** | Every email sent through the API, the SMTP relay or broadcasts. The **Receiving** tab lists mail received on your domains. | | **Broadcasts** | Write, schedule and send marketing email to a segment. | | **Templates** | Edit, test and publish templates. | | **Audiences** | Contacts, segments, topics, properties and the suppression list. | | **Automations** | Event-driven sequences and their runs. | | **Metrics** | Volume and rates per day. | | **Domains** | Sending and receiving domains, DNS records, tracking. | | **Logs** | Every API call made with your keys. | | **API Keys** | Create and revoke API keys. | | **Webhooks** | Endpoints that receive email events. | | **Email settings** | SMTP connection details, daily quota, the emergency pause and the unsubscribe page. | ## API key scopes Grant each key only the scopes the system using it needs: | Endpoints | Required scope | |---|---| | `POST /emails`, `POST /emails/batch`, the SMTP relay | `email:send` | | `PATCH /emails/{id}`, `POST /emails/{id}/cancel` | `email:send` | | `GET /emails`, `GET /emails/{id}`, `GET /emails/{id}/events` | `email:read` (or `email:send` / `email:write`, which include it) | | Domains, templates, contacts, segments, topics, broadcasts, events, automations | `email:write` | | `GET /emails/metrics` | `email:read` (or `email:send` / `email:write`, which include it) | | `GET /emails/receiving/*` | `email:read` or `email:write`, granted directly. `email:send` alone cannot read received mail. | | `GET /logs`, `GET /usage` | Any write scope | A key can also be limited to one sending domain. That key gets `401 restricted_api_key` when it sends from another domain. ## Limits at a glance | Limit | Value | |---|---| | REST API rate | 10 requests per second per business, shared by the email endpoints | | Recipients per email | 50 across `to`, `cc` and `bcc` | | Emails per batch call | 100 | | Email size | 40 MB, body and attachments together | | Scheduling | Up to 30 days ahead | | Daily quota | Set per business, resets at midnight UTC. See **Email settings** or `GET /usage`. | The full list is in [Limits](https://hatang.dev/docs/reference/limits.md) and every error name is in [Errors](https://hatang.dev/docs/reference/errors.md). ## Next steps - [Domains](https://hatang.dev/docs/guides/email/domains.md): set up DKIM, SPF and DMARC. - [Send email](https://hatang.dev/docs/guides/email/sending.md): every field of `POST /emails`. - [SMTP relay](https://hatang.dev/docs/guides/email/smtp.md): connect WordPress, Laravel, Django and others. - [Migrate from Resend](https://hatang.dev/docs/guides/email/migrate-from-resend.md): what changes when you switch. Source: https://hatang.dev/docs/guides/email/overview --- # Receive email Turn on receiving for a verified domain and every message sent to any address `@` that domain is stored by Ha Tang and announced with the `email.received` webhook. You then read the body, headers and attachments through the API. It works like Resend Receiving, so code written for Resend only needs a new base URL and key. > **Tip: Answering customers by email?** > > If your team should read and answer customer mail in a shared inbox, use the [Email support channel](https://hatang.dev/help/channels/email-channel) instead of processing webhooks yourself. ## Before you start - A verified domain. See [Domains](https://hatang.dev/docs/guides/email/domains.md). - An API key with `email:read` or `email:write` granted directly. A key with only `email:send` cannot read received mail, because it is other people's mail. - A webhook endpoint. See [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md). ## 1. Turn on receiving In the dashboard, open **Email → Domains**, pick the domain and turn on **Receiving**. Or use the API: **cURL** ```bash curl -X PATCH https://api.hatang.dev/domains/$DOMAIN_ID \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "capabilities": { "receiving": "enabled" } }' ``` **Node.js** ```ts await hatang.domains.update({ id: domainId, capabilities: { receiving: "enabled" } }); ``` **Python** ```python requests.patch( f"https://api.hatang.dev/domains/{domain_id}", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={"capabilities": {"receiving": "enabled"}}, ) ``` **PHP** ```php $client->patch("/domains/$domainId", [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['capabilities' => ['receiving' => 'enabled']], ]); ``` ## 2. Publish the MX record The domain's records now include a **Receiving** row: an MX record with priority 10 on the domain itself. Add it at your DNS host. Ha Tang checks it with the other records; it does not affect sending. > **Caution: Domains that already receive mail** > > The MX record decides where all mail for the domain goes. Ha Tang refuses to turn on receiving for a domain whose MX already points at another mail service (Google Workspace, Microsoft 365…), so you do not lose that mailbox, and for a domain that a support mailbox in Chat already receives. Add a subdomain such as `inbound.example.com` under **Email → Domains**, verify it, and receive there instead. ## 3. Handle the `email.received` webhook Create a webhook (**Webhooks** in the dashboard, or `POST /webhooks`) subscribed to `email.received`. The webhook carries a summary, not the body: ```json { "type": "email.received", "created_at": "2026-10-14T09:12:03.000Z", "data": { "email_id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10", "created_at": "2026-10-14T09:12:03.000Z", "from": "Lan Tran ", "to": ["support@example.com"], "cc": [], "bcc": [], "message_id": "", "subject": "Order #1024 has not arrived", "attachments": [ { "id": "att_…", "filename": "receipt.pdf", "content_type": "application/pdf", "content_disposition": "attachment", "content_id": null, "size": 48213 } ] } } ``` - `bcc` lists the addresses on your domain that received the message without being in `To` or `Cc`. - Messages the receiving service flags as spam are stored (with `spam: true` when you read them) but do not fire the webhook, like a spam folder. Messages with a virus are dropped. - The webhook is signed like every Ha Tang webhook. Verify the signature before you trust it; see [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md). ## 4. Read the message | Endpoint | Returns | |---|---| | `GET /emails/receiving` | Received messages, newest first (`limit`, `after`, `before`). | | `GET /emails/receiving/{id}` | One message: `text`, `html`, `headers`, `reply_to`, `in_reply_to`, `spam` and its attachments. | | `GET /emails/receiving/{id}/attachments` | The attachments, each with a `download_url`. | | `GET /emails/receiving/{id}/attachments/{attachment_id}` | One attachment with its `download_url`. | **cURL** ```bash curl https://api.hatang.dev/emails/receiving/$EMAIL_ID -H "Authorization: Bearer $HATANG_API_KEY" curl https://api.hatang.dev/emails/receiving/$EMAIL_ID/attachments -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); // In your email.received webhook handler: const { data: email } = await hatang.emails.receiving.get(event.data.email_id); console.log(email.subject, email.text); const { data: files } = await hatang.emails.receiving.attachments.list(email.id); for (const file of files.data) { const res = await fetch(file.download_url!); // store res.body somewhere } ``` **Python** ```python headers = {"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"} email = requests.get(f"https://api.hatang.dev/emails/receiving/{email_id}", headers=headers).json() files = requests.get(f"https://api.hatang.dev/emails/receiving/{email_id}/attachments", headers=headers).json() for f in files["data"]: content = requests.get(f["download_url"]).content ``` **PHP** ```php $headers = ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')]; $email = json_decode($client->get("/emails/receiving/$emailId", ['headers' => $headers])->getBody(), true); $files = json_decode($client->get("/emails/receiving/$emailId/attachments", ['headers' => $headers])->getBody(), true); ``` A `download_url` works without an API key and expires one hour after you fetched it (`expires_at`), so you can hand it to a browser or another worker. Fetch the attachment again to get a fresh URL. In the dashboard, received mail is under **Email → Emails → Receiving**. ## Retention Received messages and their attachments are kept for 30 days and then deleted. To keep them longer, copy them to your own storage when the webhook arrives. ## Next steps - [Email groups](https://hatang.dev/docs/guides/email/groups.md): forward one address, like team@example.com, to several people - [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md) - [Email support channel](https://hatang.dev/help/channels/email-channel) - [Domains](https://hatang.dev/docs/guides/email/domains.md) Source: https://hatang.dev/docs/guides/email/receiving --- # Send email `POST /emails` sends one email to up to 50 recipients. `POST /emails/batch` sends up to 100 different emails in one call. This page covers every field, the response, scheduling, idempotency, the limits, and how to follow an email through its statuses and events. ## Before you start - A verified domain. See [Domains](https://hatang.dev/docs/guides/email/domains.md). - An API key with the `email:send` scope. See [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md). - Requests go to `https://api.hatang.dev` with `Authorization: Bearer ht_...` and `Content-Type: application/json`. ## Send an email **cURL** ```bash curl -X POST https://api.hatang.dev/emails \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1024-confirmation" \ -d '{ "from": "Acme ", "to": ["lan@example.net"], "cc": ["accounting@example.com"], "reply_to": "support@example.com", "subject": "Order #1024 is confirmed", "html": "

Hi Lan, your order #1024 is confirmed.

", "text": "Hi Lan, your order #1024 is confirmed.", "tags": [{ "name": "category", "value": "order_confirmation" }], "headers": { "X-Order-Id": "1024" } }' ``` **Node.js** ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data, error } = await hatang.emails.send( { from: "Acme ", to: ["lan@example.net"], cc: ["accounting@example.com"], reply_to: "support@example.com", subject: "Order #1024 is confirmed", html: "

Hi Lan, your order #1024 is confirmed.

", text: "Hi Lan, your order #1024 is confirmed.", tags: [{ name: "category", value: "order_confirmation" }], headers: { "X-Order-Id": "1024" }, }, { idempotencyKey: "order-1024-confirmation" }, ); if (error) throw new Error(`${error.name}: ${error.message}`); console.log(data.id); ``` **Python** ```python from hatang import Hatang hatang = Hatang() # reads HATANG_API_KEY email = hatang.emails.send( { "from": "Acme ", "to": ["lan@example.net"], "cc": ["accounting@example.com"], "reply_to": "support@example.com", "subject": "Order #1024 is confirmed", "html": "

Hi Lan, your order #1024 is confirmed.

", "text": "Hi Lan, your order #1024 is confirmed.", "tags": [{"name": "category", "value": "order_confirmation"}], "headers": {"X-Order-Id": "1024"}, }, idempotency_key="order-1024-confirmation", ) print(email["id"]) # a failed call raises hatang.HatangError ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->post('/emails', [ 'headers' => [ 'Authorization' => 'Bearer ' . getenv('HATANG_API_KEY'), 'Idempotency-Key' => 'order-1024-confirmation', ], 'json' => [ 'from' => 'Acme ', 'to' => ['lan@example.net'], 'cc' => ['accounting@example.com'], 'reply_to' => 'support@example.com', 'subject' => 'Order #1024 is confirmed', 'html' => '

Hi Lan, your order #1024 is confirmed.

', 'text' => 'Hi Lan, your order #1024 is confirmed.', 'tags' => [['name' => 'category', 'value' => 'order_confirmation']], 'headers' => ['X-Order-Id' => '1024'], ], ]); echo json_decode($res->getBody(), true)['id']; ``` **Go** ```go body := strings.NewReader(`{ "from": "Acme ", "to": ["lan@example.net"], "subject": "Order #1024 is confirmed", "html": "

Your order #1024 is confirmed.

" }`) req, _ := http.NewRequest("POST", "https://api.hatang.dev/emails", body) req.Header.Set("Authorization", "Bearer "+os.Getenv("HATANG_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", "order-1024-confirmation") res, err := http.DefaultClient.Do(req) ``` The response is `200` with the new email's id (a UUID): ```json { "id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10" } ``` A `200` means Ha Tang accepted the email and queued it. Delivery happens afterwards; follow it with [statuses and events](#statuses-and-events). ## Request fields | Field | Type | Description | |---|---|---| | `from` | string | Sender, `hello@example.com` or `Acme `. The domain must be verified. Optional only when a `template` provides it. | | `to` | string or array | Recipients. | | `cc` | string or array | Carbon copy recipients. | | `bcc` | string or array | Blind copy recipients. They never appear in the message headers. | | `reply_to` | string or array | Reply-To addresses, up to 50. | | `subject` | string | Up to 998 characters. Required unless a `template` provides it. | | `html` | string | HTML body, up to 10,000,000 characters. | | `text` | string | Plain-text body, up to 10,000,000 characters. | | `template` | object | `{ "id": "tpl_… or alias", "variables": { … } }` sends a published template instead of `html`/`text`. See [Templates](https://hatang.dev/docs/guides/email/templates.md). | | `headers` | object | Extra headers, for example `{ "X-Order-Id": "1024" }`. | | `tags` | array | Up to 50 `{ "name", "value" }` pairs, echoed in webhooks. | | `attachments` | array | Up to 50 files. See [Attachments](#attachments). | | `scheduled_at` | string | Send later. See [Schedule an email](#schedule-an-email). | | `topic_id` | string | Skip recipients who opted out of this topic. See [Contacts, segments and topics](https://hatang.dev/docs/guides/email/audiences.md). | You must send `html`, `text` or `template`. A request with `template` cannot also have `html` or `text`. > **Tip: Send both HTML and text** > > Ha Tang builds the plain-text part from the HTML only for templates. For direct sends, pass both `html` and `text`: some mail clients and spam filters look at the text part. ### Recipients - Addresses can be plain (`lan@example.net`) or have a display name (`Lan `). - `to`, `cc` and `bcc` together hold at most **50** addresses. To reach more people, send one email per recipient (with `/emails/batch`) or use a [broadcast](https://hatang.dev/docs/guides/email/broadcasts.md). - Each recipient is delivered and tracked separately. Recipients on your [suppression list](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md) are skipped and reported as bounced. ### Headers Use `headers` for custom headers such as `X-Order-Id` or `List-Unsubscribe`. Ha Tang drops the headers it sets itself: `From`, `To`, `Cc`, `Bcc`, `Subject`, `Date`, `Message-ID`, `Content-Type`, `Content-Transfer-Encoding`, `MIME-Version`, `Return-Path`, `Received`, `DKIM-Signature` and `Authentication-Results`. Every message also carries `X-Entity-Ref-ID` with the email id. ### Tags Tags label an email for your own systems. `name` and `value` are each 1 to 256 characters of ASCII letters, digits, `_` and `-`. Webhooks carry them as an object, for example `"tags": { "category": "order_confirmation" }`. ### Attachments {#attachments} Send each file's bytes as base64 in `content`: ```json { "attachments": [ { "filename": "invoice-1024.pdf", "content": "JVBERi0xLjcKJc…", "content_type": "application/pdf" }, { "filename": "logo.png", "content": "iVBORw0KGgo…", "content_type": "image/png", "content_id": "logo" } ] } ``` | Field | Description | |---|---| | `filename` | File name shown to the recipient, up to 255 characters. | | `content` | The file as a base64 string. The Node.js SDK also accepts a `Buffer` or `Uint8Array` and encodes it for you. | | `content_type` | MIME type. Optional. | | `content_id` | Set it to show the file inline: reference it in the HTML as ``. | > **Caution: `path` is not supported yet** > > Attachments by URL (`path`) are refused with `422 invalid_attachment`. Download the file yourself and send it as `content`. The whole email, bodies and decoded attachments together, must stay under **40 MB**. ### React Email The Node.js SDK accepts a `react` element and renders it to `html` and `text` before the request (the API itself only sees `html` and `text`). Install `@react-email/render` and `react` next to the SDK: ```tsx import { Hatang } from "@hatang/sdk"; import { WelcomeEmail } from "./emails/welcome"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); await hatang.emails.send({ from: "Acme ", to: "lan@example.net", subject: "Welcome to Acme", react: , }); ``` ## Schedule an email {#schedule-an-email} Set `scheduled_at` to an ISO 8601 date with a time zone, or to a phrase such as `in 1 hour`, `in 30 minutes` or `in 2 days`. An ISO date can be at most 30 days ahead. ```json { "from": "Acme ", "to": ["lan@example.net"], "subject": "Your trial ends tomorrow", "html": "

…

", "scheduled_at": "2026-10-20T09:00:00+07:00" } ``` The email gets the `scheduled` status. Until it is sent you can move it or cancel it: **cURL** ```bash # Move it curl -X PATCH https://api.hatang.dev/emails/$EMAIL_ID \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "scheduled_at": "in 3 hours" }' # Cancel it curl -X POST https://api.hatang.dev/emails/$EMAIL_ID/cancel -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts await hatang.emails.update({ id: emailId, scheduledAt: "in 3 hours" }); await hatang.emails.cancel(emailId); ``` **Python** ```python hatang.emails.update(email_id, scheduled_at="in 3 hours") hatang.emails.cancel(email_id) ``` **PHP** ```php $headers = ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')]; $client->patch("/emails/$emailId", ['headers' => $headers, 'json' => ['scheduled_at' => 'in 3 hours']]); $client->post("/emails/$emailId/cancel", ['headers' => $headers]); ``` Both answer `{ "object": "email", "id": "…" }`. Rescheduling or canceling an email that is no longer `scheduled` returns `422`. A canceled email gets the `canceled` status. ## Send a batch `POST /emails/batch` takes an array of 1 to 100 emails. Each item has the same fields as `POST /emails`, except that `attachments` and `scheduled_at` are not allowed. **cURL** ```bash curl -X POST https://api.hatang.dev/emails/batch \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '[ { "from": "Acme ", "to": ["lan@example.net"], "subject": "Hi Lan", "html": "

Hi Lan

" }, { "from": "Acme ", "to": ["minh@example.org"], "subject": "Hi Minh", "html": "

Hi Minh

" } ]' ``` **Node.js** ```ts const { data, error } = await hatang.batch.send([ { from: "Acme ", to: ["lan@example.net"], subject: "Hi Lan", html: "

Hi Lan

" }, { from: "Acme ", to: ["minh@example.org"], subject: "Hi Minh", html: "

Hi Minh

" }, ]); ``` **Python** ```python result = hatang.batch.send([ {"from": "Acme ", "to": ["lan@example.net"], "subject": "Hi Lan", "html": "

Hi Lan

"}, {"from": "Acme ", "to": ["minh@example.org"], "subject": "Hi Minh", "html": "

Hi Minh

"}, ]) ``` **PHP** ```php $res = $client->post('/emails/batch', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ ['from' => 'Acme ', 'to' => ['lan@example.net'], 'subject' => 'Hi Lan', 'html' => '

Hi Lan

'], ['from' => 'Acme ', 'to' => ['minh@example.org'], 'subject' => 'Hi Minh', 'html' => '

Hi Minh

'], ], ]); ``` ```json { "data": [{ "id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10" }, { "id": "0199e6c1-7a2c-7c41-8f0e-61f1b2a3c4d5" }] } ``` The ids are in the same order as the items. > **Caution: A batch is not all-or-nothing** > > Ha Tang validates the shape of every item before sending anything. Checks that need your account (the `from` domain, templates, the daily quota) run item by item while sending, so if item 5 fails, items 1 to 4 are already sent and the call returns the error. Make sure every `from` domain is verified and you have quota left before a large batch. The batch endpoint does not support `Idempotency-Key`. ## Idempotency {#idempotency} Network errors happen. To retry `POST /emails` safely, send an `Idempotency-Key` header (in the Node.js SDK, the `idempotencyKey` option). Use a key that identifies the message in your system, such as `order-1024-confirmation`. | Situation | Result | |---|---| | First request with the key | The email is created and sent. | | Same key, same body | `200` with the id of the first email. Nothing is sent again and no quota is used. | | Same key, different body | `400 invalid_idempotent_request` | | Same key while the first request is still running | `409 concurrent_idempotent_requests`. Retry after a moment. | Keys belong to your business: two different businesses can use the same key without conflict. ## Limits | Limit | Value | |---|---| | Recipients per email | 50 across `to`, `cc` and `bcc` | | `reply_to` addresses | 50 | | Subject | 998 characters | | `html`, `text` | 10,000,000 characters each | | Attachments | 50 per email | | Email size | 40 MB, bodies and attachments together | | Tags | 50 per email, 256 characters per name or value | | Batch | 100 emails per call | | Scheduling | 30 days ahead | | Request rate | 10 requests per second per business. Responses carry `ratelimit-limit`, `ratelimit-remaining` and `ratelimit-reset`; a `429` also has `retry-after`. | | Daily quota | Each email (each batch item, each broadcast email) counts as one. Resets at midnight UTC. `GET /usage` shows what is left. | ## Errors Errors have the same shape everywhere: ```json { "statusCode": 403, "name": "validation_error", "message": "The example.com domain is not verified. Please, add and verify your domain first." } ``` | Status | `name` | When | |---|---|---| | 422 | `validation_error` | A field is missing or invalid, for example no `html`/`text`/`template`, more than 50 recipients, or a `scheduled_at` more than 30 days ahead. | | 403 | `validation_error` | The `from` domain is not added to your business or not verified. | | 401 | `restricted_api_key` | The API key is limited to another sending domain. | | 422 | `invalid_attachment` | An attachment uses `path`. | | 403 | `application_error` | Sending is paused for your business (**Email settings**). | | 429 | `rate_limit_exceeded` | More than 10 requests per second. | | 429 | `daily_quota_exceeded` | The daily quota is used up. | | 400 | `invalid_idempotent_request` | The idempotency key was used with a different body. | | 409 | `concurrent_idempotent_requests` | The same idempotency key is in flight. | The full list is in [Errors](https://hatang.dev/docs/reference/errors.md). ## Retrieve and list emails `GET /emails/{id}` returns one email: ```json { "object": "email", "id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10", "to": ["lan@example.net"], "from": "Acme ", "created_at": "2026-10-14T09:12:03.000Z", "subject": "Order #1024 is confirmed", "html": "

Hi Lan, …

", "text": "Hi Lan, …", "bcc": null, "cc": ["accounting@example.com"], "reply_to": ["support@example.com"], "last_event": "delivered", "status": "delivered", "scheduled_at": null, "tags": [{ "name": "category", "value": "order_confirmation" }], "headers": { "X-Order-Id": "1024" } } ``` `GET /emails` lists emails, newest first: | Query parameter | Description | |---|---| | `limit` | 1 to 100, default 20. | | `after`, `before` | Pagination cursors: pass the id of the last (or first) email of the page you have. | | `status` | One or more statuses separated by commas, for example `bounced,complained`. | | `source` | `api`, `smtp`, `broadcast`, `chat` or `system`. | | `q` | Search the subject, the sender, the `to` addresses, or an exact email id. | `GET /emails/{id}/events` returns every event of the email and the state of each recipient, including the last SMTP response: ```json { "object": "list", "data": [ { "id": "evt_…", "type": "email.sent", "created_at": "2026-10-14T09:12:03.000Z", "data": { "source": "api" } }, { "id": "evt_…", "type": "email.delivered", "created_at": "2026-10-14T09:12:05.000Z", "data": { "address": "lan@example.net", "code": 250 } } ], "recipients": [ { "address": "lan@example.net", "kind": "to", "status": "delivered", "attempts": 1, "last_smtp_code": 250, "last_smtp_response": "250 Ok", "next_attempt_at": null } ] } ``` ## Statuses and events {#statuses-and-events} An email's `status` sums up its recipients: | Status | Meaning | |---|---| | `scheduled` | Waiting for `scheduled_at`. | | `queued` | Accepted and waiting to be handed to the mail servers. | | `sent` | Handed off; at least one recipient has no final result yet. | | `delivered` | Every recipient that was sent to has been delivered. When some recipients bounced and others were delivered, the status is also `delivered`; check the recipients for details. | | `delivery_delayed` | Delivery to at least one recipient is being retried. | | `bounced` | No recipient could be reached, including when every recipient was on the suppression list. | | `complained` | A recipient marked the email as spam. | | `canceled` | A scheduled email was canceled. | | `failed` | Ha Tang could not send the email. | Each recipient has its own state in `GET /emails/{id}/events`: `pending`, `sent`, `delivered`, `delayed`, `bounced`, `suppressed` or `canceled`. These webhook events follow an email. Subscribe to them under **Webhooks** in the dashboard or with `POST /webhooks`; see [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md). | Event | When | |---|---| | `email.sent` | Ha Tang accepted the email. | | `email.delivered` | A recipient's mail server accepted the message. | | `email.delivery_delayed` | Delivery is being retried. Sent on the first delay, then at most once an hour per recipient. | | `email.bounced` | A recipient bounced, or was skipped because the address is suppressed (`bounce.subType` is `Suppressed`). | | `email.complained` | A recipient reported the email as spam. | | `email.opened` | A recipient opened the email. Needs open tracking; sent at most once an hour per recipient. | | `email.clicked` | A recipient clicked a link. Needs click tracking. | | `email.failed` | The email could not be sent. | A bounce webhook includes the reason: ```json { "type": "email.bounced", "created_at": "2026-10-14T09:12:07.000Z", "data": { "email_id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10", "from": "Acme ", "to": ["lan@example.net"], "subject": "Order #1024 is confirmed", "created_at": "2026-10-14T09:12:03.000Z", "tags": { "category": "order_confirmation" }, "bounce": { "type": "Permanent", "subType": "NoEmail", "message": "550 5.1.1 The email account that you tried to reach does not exist." } } } ``` ## Next steps - [Templates](https://hatang.dev/docs/guides/email/templates.md): send by template id with variables. - [SMTP relay](https://hatang.dev/docs/guides/email/smtp.md): send from apps that only speak SMTP. - [Suppressions and bounces](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md): what happens after a bounce. - [REST API reference](https://hatang.dev/docs/reference/api.md) Source: https://hatang.dev/docs/guides/email/sending --- # SMTP relay The SMTP relay lets any app that can send mail over SMTP use Ha Tang: WordPress, Laravel, Django, Rails, Spring Boot, a Postfix server, a printer. Messages go through exactly the same checks as `POST /emails`, so they show up under **Email → Emails** with their status, events and webhooks. ## Before you start - A verified domain for the `From` address. See [Domains](https://hatang.dev/docs/guides/email/domains.md). - An API key with the `email:send` scope. It is your SMTP password. ## Connection settings | Setting | Value | |---|---| | Host | `smtp.hatang.dev` | | Port | `5870` | | Encryption | STARTTLS (the connection starts in plain text and upgrades with `STARTTLS`) | | Username | `hatang` | | Password | Your API key, `ht_...` | | Authentication | `PLAIN` or `LOGIN` | The same values are under **Email settings → Connection details** in the dashboard. > **Caution: Port 5870 is STARTTLS, not implicit TLS** > > Do not turn on "SSL", "implicit TLS" or `secure: true` for port 5870: the client would try a TLS handshake before the SMTP greeting and time out. Choose STARTTLS, which some apps call "TLS". The server refuses to authenticate until the connection has been upgraded with `STARTTLS`, so your API key never crosses the network in clear text. ## How a message is read | Part of the message | What Ha Tang does with it | |---|---| | `From` header | The sender. Its domain must be verified and belong to the business of the API key. If there is no `From` header, the envelope sender (`MAIL FROM`) is used. | | `To` and `Cc` headers | The `to` and `cc` recipients. | | Envelope recipients (`RCPT TO`) that are not in `To` or `Cc` | Sent as `bcc`. | | `Reply-To`, `Subject`, the HTML and text parts, attachments, inline images | Kept as they are. | | `Message-ID` | Kept, so threads in your own system still match. | | Headers that start with `X-` | Passed on as custom headers, like the `headers` field of the API. Other custom headers are dropped. | | `X-Tags` | Read as tags and not passed on. The value is a JSON array: `X-Tags: [{"name":"category","value":"welcome"}]`. A value that is not valid JSON is ignored. | > **Tip: Always fill in `To`** > > If a message only has Bcc recipients, Ha Tang moves the first one into `To`, where every other recipient can see it. Put at least one address in the `To` header. ## Limits | Limit | Value | |---|---| | Recipients per message | 50 envelope recipients (`RCPT TO`) | | Message size | Messages over 40 MB are refused. | | Failed logins | After 10 failed logins from one IP address, logins from that address are refused until 15 minutes have passed since the last failure. | | Daily quota | Shared with the API: each message counts as one email. | ## SMTP replies | Reply | Enhanced code | Meaning | What to do | |---|---|---|---| | `250` | `2.0.0` | Accepted. The reply text ends with `queued as `. | Nothing. | | `530` | `5.7.0` | Authentication required. | Log in before `MAIL FROM`. | | `535` | `5.7.8` | The API key is wrong or revoked. | Check the password. | | `421` | `4.7.0` | Too many failed logins from your IP address. | Fix the password and wait before retrying. | | `452` | `5.5.3` | More than 50 recipients. | Split the message. | | `550` | `5.7.1` | The `From` domain is not verified or not in your business, the API key is limited to another domain, or sending is paused. The text says which. | Verify the domain or fix the `From` address. | | `553` | `5.6.0` | The message was refused as invalid, for example an invalid `From` header. | Fix the message. Retrying the same message fails again. | | `451` | `4.3.0` | Temporary problem, including a used-up daily quota. | Retry later. Mail servers such as Postfix retry by themselves. | ## Test the connection Check that STARTTLS works: ```bash openssl s_client -starttls smtp -connect smtp.hatang.dev:5870 -crlf -quiet ``` Send a test message with [swaks](https://github.com/jetmore/swaks): ```bash swaks --server smtp.hatang.dev:5870 --tls \ --auth PLAIN --auth-user hatang --auth-password "$HATANG_API_KEY" \ --from hello@example.com --to you@example.net \ --header "Subject: SMTP test" --body "It works." ``` Or with Python's standard library: ```python import os import smtplib from email.message import EmailMessage msg = EmailMessage() msg["From"] = "Acme " msg["To"] = "you@example.net" msg["Subject"] = "SMTP test" msg["X-Tags"] = '[{"name":"category","value":"test"}]' msg.set_content("It works.") with smtplib.SMTP("smtp.hatang.dev", 5870) as smtp: smtp.starttls() smtp.login("hatang", os.environ["HATANG_API_KEY"]) smtp.send_message(msg) ``` ## Recipes ### Nodemailer ```js import nodemailer from "nodemailer"; const transporter = nodemailer.createTransport({ host: "smtp.hatang.dev", port: 5870, secure: false, // must be false: port 5870 uses STARTTLS requireTLS: true, // refuse to send if STARTTLS is not available auth: { user: "hatang", pass: process.env.HATANG_API_KEY }, }); const info = await transporter.sendMail({ from: "Acme ", to: "lan@example.net", subject: "Welcome to Acme", html: "

Welcome!

", text: "Welcome!", headers: { "X-Tags": JSON.stringify([{ name: "category", value: "welcome" }]) }, }); console.log(info.response); // 250 2.0.0 Ok: queued as ``` `secure: true` means implicit TLS and does not work on port 5870. ### Laravel In `.env`: ```bash MAIL_MAILER=smtp MAIL_HOST=smtp.hatang.dev MAIL_PORT=5870 MAIL_USERNAME=hatang MAIL_PASSWORD=ht_123... MAIL_FROM_ADDRESS=hello@example.com MAIL_FROM_NAME="Acme" ``` Laravel's mailer upgrades the connection with STARTTLS when the server offers it, which `smtp.hatang.dev` always does. Leave `MAIL_SCHEME` empty or set it to `smtp`; do not use `smtps`. On Laravel 10 and older, set `MAIL_ENCRYPTION=tls`. ### Django In `settings.py`: ```python import os EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST = "smtp.hatang.dev" EMAIL_PORT = 5870 EMAIL_HOST_USER = "hatang" EMAIL_HOST_PASSWORD = os.environ["HATANG_API_KEY"] EMAIL_USE_TLS = True # STARTTLS EMAIL_USE_SSL = False # implicit TLS: must stay off DEFAULT_FROM_EMAIL = "Acme " ``` ### Ruby on Rails In `config/environments/production.rb`: ```ruby config.action_mailer.delivery_method = :smtp config.action_mailer.smtp_settings = { address: "smtp.hatang.dev", port: 5870, user_name: "hatang", password: ENV.fetch("HATANG_API_KEY"), authentication: :plain, enable_starttls_auto: true } ``` ### Spring Boot In `application.properties`: ```text spring.mail.host=smtp.hatang.dev spring.mail.port=5870 spring.mail.username=hatang spring.mail.password=${HATANG_API_KEY} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true spring.mail.properties.mail.smtp.starttls.required=true ``` ### WordPress (WP Mail SMTP) Install the [WP Mail SMTP](https://wordpress.org/plugins/wp-mail-smtp/) plugin, open **WP Mail SMTP → Settings** and choose the **Other SMTP** mailer: | Setting | Value | |---|---| | From Email | An address on your verified domain, for example `hello@example.com`. Turn on **Force From Email**. | | SMTP Host | `smtp.hatang.dev` | | Encryption | **TLS** (this is STARTTLS in WP Mail SMTP) | | SMTP Port | `5870` | | Auto TLS | On | | Authentication | On | | SMTP Username | `hatang` | | SMTP Password | Your API key | To keep the key out of the database, define it in `wp-config.php` instead: ```php define( 'WPMS_ON', true ); define( 'WPMS_MAILER', 'smtp' ); define( 'WPMS_SMTP_HOST', 'smtp.hatang.dev' ); define( 'WPMS_SMTP_PORT', 5870 ); define( 'WPMS_SSL', 'tls' ); define( 'WPMS_SMTP_AUTH', true ); define( 'WPMS_SMTP_USER', 'hatang' ); define( 'WPMS_SMTP_PASS', 'ht_123...' ); define( 'WPMS_MAIL_FROM', 'hello@example.com' ); define( 'WPMS_MAIL_FROM_FORCE', true ); ``` Then send a test from **WP Mail SMTP → Tools → Email Test**. ### PHPMailer ```php isSMTP(); $mail->Host = 'smtp.hatang.dev'; $mail->Port = 5870; $mail->SMTPAuth = true; $mail->Username = 'hatang'; $mail->Password = getenv('HATANG_API_KEY'); $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->CharSet = 'UTF-8'; $mail->setFrom('hello@example.com', 'Acme'); $mail->addAddress('lan@example.net'); $mail->Subject = 'Welcome to Acme'; $mail->isHTML(true); $mail->Body = '

Welcome!

'; $mail->AltBody = 'Welcome!'; $mail->addCustomHeader('X-Tags', '[{"name":"category","value":"welcome"}]'); $mail->send(); ``` ### Postfix relayhost To send all mail from a server through Ha Tang, set Ha Tang as the relay host in `/etc/postfix/main.cf`: ```text relayhost = [smtp.hatang.dev]:5870 smtp_sasl_auth_enable = yes smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd smtp_sasl_security_options = noanonymous smtp_tls_security_level = encrypt ``` Put the credentials in `/etc/postfix/sasl_passwd`: ```text [smtp.hatang.dev]:5870 hatang:ht_123... ``` Then protect the file and reload: ```bash sudo chmod 600 /etc/postfix/sasl_passwd sudo postmap /etc/postfix/sasl_passwd sudo systemctl reload postfix ``` Only mail with a `From` address on a verified domain is accepted. Rewrite the sender of system mail (for example with `smtp_generic_maps`) so `root@localhost` becomes an address on your domain. ## Next steps - [Send email](https://hatang.dev/docs/guides/email/sending.md): the REST API, with batch sends, scheduling and idempotency. - [Open and click tracking](https://hatang.dev/docs/guides/email/tracking.md): tracking also applies to mail sent over SMTP. - [Suppressions and bounces](https://hatang.dev/docs/guides/email/suppressions-and-bounces.md) Source: https://hatang.dev/docs/guides/email/smtp --- # Suppressions and bounces Sending again to an address that does not exist, or to someone who marked your mail as spam, hurts your sender reputation fast. Ha Tang keeps a **suppression list** per business and stops sending to those addresses automatically. This page explains what adds an address to the list, what happens when you send to it, and how to see and remove entries. ## What adds an address | Reason | Dashboard label | Added when | |---|---|---| | `hard_bounce` | hard bounce | A message bounced permanently because the mailbox does not exist: the bounce has the `NoEmail` subtype or an SMTP status starting with `5.1.` (for example `550 5.1.1 User unknown`). | | `complaint` | complaint | The recipient marked a message as spam in their mail client. | | `unsubscribe` | unsubscribe | A contact unsubscribed from everything: on the preferences page, with one-click unsubscribe from a broadcast without a topic, through the API (`"unsubscribed": true`), with an automation's `update_contact` step, or from a CSV import. | | `manual` | manual | Someone on your team added the address under **Suppressions**. | The list belongs to your business and applies to every email you send: through the API, the SMTP relay, broadcasts and automations. Addresses are compared in lower case. ### Bounces that do not suppress Not every bounce means the address is bad. These are recorded on the email but do **not** add the address to the list: - **Temporary (soft) bounces**: a full mailbox, a server that is down, a message too large. They come back as `email.bounced` with `bounce.type` `Transient`. - **Permanent rejections that are about the message, not the address**: content or policy blocks (`5.7.x`), for example. They come back with `bounce.type` `Permanent` and a subtype such as `Policy` or `ContentRejected`. Watch these in your webhooks and metrics: a rise in policy rejections usually points to a content or reputation problem. See [Deliverability](https://hatang.dev/docs/guides/email/deliverability.md). ## What happens when you send to a suppressed address Ha Tang still accepts the request, then skips that recipient: - The recipient's state is `suppressed`, and nothing is sent to it. - An `email.bounced` webhook fires for that recipient, with `bounce.subType` set to `Suppressed`: ```json { "type": "email.bounced", "data": { "email_id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10", "to": ["old-address@example.net"], "subject": "Your invoice", "bounce": { "type": "Permanent", "subType": "Suppressed", "message": "Recipient old-address@example.net is on the suppression list (hard_bounce)" } } } ``` - When every recipient of the email is suppressed, the email's status is `bounced` right away. - The email still counts toward your daily quota. - In [metrics](https://hatang.dev/docs/guides/email/metrics-and-logs.md), these recipients count under `suppressed`, and their `email.bounced` events also count under `bounced`. Clean your lists so they do not inflate your bounce rate. Recipients skipped because of a `topic_id` they left are reported the same way (the reason is `topic_opt_out`), but they are not added to the suppression list. > **Note: Unsubscribes block transactional mail too** > > An `unsubscribe` entry blocks every email to that address, including receipts and password resets sent through `POST /emails`. If someone only wants fewer marketing emails, let them leave a [topic](https://hatang.dev/docs/guides/email/audiences.md) instead of unsubscribing from everything. ## See the list Open **Email → Audiences → Suppressions**. The list shows each address, the reason, a note (for a hard bounce, the bounce message from the receiving server) and when it was added. There is no API for the suppression list. To keep your own records in sync, listen to these webhooks: | Webhook | Use it to | |---|---| | `email.bounced` | Mark the address as invalid when `bounce.type` is `Permanent` and the subtype is `NoEmail`. | | `email.complained` | Stop all marketing to that person. | | `contact.updated` | Notice when `unsubscribed` changes. | ## Add or remove an address On the **Suppressions** tab, type an address and click **Add** to block it by hand (reason `manual`). Click **Remove** next to an address to delete its entry; you need the permission to manage email. Remove an entry only when you are sure: - **Hard bounce**: the address has been fixed or the mailbox now exists, for example after the customer corrected a typo in their account. - **Complaint**: the person asked to get your email again. Sending to people who complained is the fastest way to get your domain blocked. - **Unsubscribe**: resubscribe the contact instead, so the contact record and the list stay consistent. Click **Resubscribe** on the contact, or send `PATCH /contacts/{id or email}` with `{ "unsubscribed": false }`; that removes the `unsubscribe` entry for you. Removing only the list entry lets transactional email through again, but the contact still does not receive broadcasts while it is marked unsubscribed. ## Next steps - [Deliverability](https://hatang.dev/docs/guides/email/deliverability.md) - [Contacts, segments and topics](https://hatang.dev/docs/guides/email/audiences.md) - [Metrics and logs](https://hatang.dev/docs/guides/email/metrics-and-logs.md) Source: https://hatang.dev/docs/guides/email/suppressions-and-bounces --- # Templates A template is an email your team writes once and sends many times: an order confirmation, a password reset, a welcome email. You write the HTML, declare its variables, publish it, and then send it with just its id or alias and the variable values. The API works like Resend Templates. ## Before you start - Managing templates through the API needs a key with `email:write`. For a key that only sends with templates, grant `email:send`. - The template's `from` address, or the one you pass when sending, must be on a verified domain. ## Write and publish Open **Email → Templates → New template**. The editor has: - **Defaults**: sender, subject, Reply-To and folder. A send can override each of them. - **HTML** with syntax highlighting, a **Preview** tab filled with the fallback values, and a **Plain text** tab. Leave plain text empty and Ha Tang generates it from the HTML. - **Variables**: declare every variable the subject and body use, as `string` or `number`, with an optional fallback. **Declare all** adds the ones Ha Tang finds in the content. - **Check links** reports links that are empty, only `#`, on a placeholder domain (`example.com`, `yourdomain.com`, `localhost`…), not `http(s)`, unreachable, or answering with an error page. Link problems hold back publishing until you choose **Publish anyway**. - **Send test** to your own address, **Duplicate** and **Publish**. Only a published version can be sent. You can keep editing the draft: sends keep using the published version until you publish again. The list shows **Unpublished changes** when the draft differs from what is published. ## Variables Write variables as `{{{NAME}}}`, or `{{{NAME|default}}}` with an inline default: ```html

Hi {{{NAME|there}}}, order {{{ORDER}}} for {{{TOTAL}}} VND is confirmed.

``` - Keys use letters, digits and `_`, up to 50 characters. A template declares at most 50 variables, and each value is at most 2,000 characters. - Values are HTML-escaped when they go into the HTML part. - A `number` variable refuses values that are not numbers. - The value comes from the send; if there is none, from the inline default; if there is none, from the declared fallback. A variable with none of these makes the send fail with `422` and nothing is sent. - Publishing fails while the content uses a variable that is not declared. - These names are reserved and filled for each recipient by broadcasts and automations: `{{{FIRST_NAME}}}`, `{{{LAST_NAME}}}`, `{{{EMAIL}}}`, `{{{UNSUBSCRIBE_URL}}}` (and its alias `{{{RESEND_UNSUBSCRIBE_URL}}}`). Contact properties are available as `{{{contact.plan}}}`; see [Contacts, segments and topics](https://hatang.dev/docs/guides/email/audiences.md). You do not declare these. ## Send with a template **cURL** ```bash curl -X POST https://api.hatang.dev/emails \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": ["lan@example.net"], "template": { "id": "order-confirmation", "variables": { "NAME": "Lan", "ORDER": "DH1024", "TOTAL": 450000 } } }' ``` **Node.js** ```ts const { data, error } = await hatang.emails.send({ to: "lan@example.net", template: { id: "order-confirmation", variables: { NAME: "Lan", ORDER: "DH1024", TOTAL: 450000 } }, }); ``` **Python** ```python hatang.emails.send({ "to": ["lan@example.net"], "template": {"id": "order-confirmation", "variables": {"NAME": "Lan", "ORDER": "DH1024", "TOTAL": 450000}}, }) ``` **PHP** ```php $res = $client->post('/emails', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'to' => ['lan@example.net'], 'template' => ['id' => 'order-confirmation', 'variables' => ['NAME' => 'Lan', 'ORDER' => 'DH1024', 'TOTAL' => 450000]], ], ]); ``` - `template.id` is the template id (`tpl_…`) or its alias. - `from`, `subject` and `reply_to` in the request override the template's defaults. They are required when the template has none. - Do not send `html` or `text` together with `template`. - `POST /emails/batch` accepts `template` in each item. - To start a broadcast from a template, pass `template_id` to `POST /broadcasts`, or choose **Start from a template** in the broadcast editor. See [Broadcasts](https://hatang.dev/docs/guides/email/broadcasts.md). | Error | Cause | |---|---| | `404 not_found` | No template has this id or alias. | | `422 validation_error` "is not published yet" | Publish the template first. | | `422 validation_error` "Missing value for template variable" | A variable has no value, no inline default and no fallback. | | `422 validation_error` "The template has no `from`" | Pass `from` in the request or set it on the template. | ## Manage templates with the API ```bash curl -X POST https://api.hatang.dev/templates \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Order confirmation", "alias": "order-confirmation", "from": "Acme ", "subject": "Order {{{ORDER}}} is confirmed", "html": "

Hi {{{NAME|there}}}, order {{{ORDER}}} for {{{TOTAL}}} VND is confirmed.

", "variables": [ { "key": "NAME", "type": "string" }, { "key": "ORDER", "type": "string" }, { "key": "TOTAL", "type": "number", "fallback_value": 0 } ] }' # { "object": "template", "id": "tpl_…" } curl -X POST https://api.hatang.dev/templates/order-confirmation/publish -H "Authorization: Bearer $HATANG_API_KEY" # { "object": "template", "id": "tpl_…", "current_version_id": "tplv_…" } ``` | Endpoint | Description | |---|---| | `POST /templates` | Create a draft: `name`, `html` (required), `alias`, `from`, `subject`, `reply_to`, `text`, `variables`, `folder`. | | `GET /templates` | List templates. Filters: `q` (name or alias), `folder`, `status` (`draft` or `published`), plus `limit` and `after`. | | `GET /templates/{id or alias}` | One template, with `status`, `current_version_id`, `published_at` and `has_unpublished_versions`. | | `PATCH /templates/{id or alias}` | Change the draft. | | `POST /templates/{id or alias}/publish` | Publish the draft. | | `POST /templates/{id or alias}/duplicate` | Copy it into a new draft named "(copy)". | | `DELETE /templates/{id or alias}` | Delete it. | An alias uses lowercase letters, digits, `-` and `_`, up to 64 characters, and is unique in your business. The Node.js SDK has the same calls under `hatang.templates`: `create`, `get`, `list`, `update`, `publish`, `duplicate` and `remove`. ## Next steps - [Send email](https://hatang.dev/docs/guides/email/sending.md) - [Broadcasts](https://hatang.dev/docs/guides/email/broadcasts.md) - [Automations](https://hatang.dev/docs/guides/email/automations.md) Source: https://hatang.dev/docs/guides/email/templates --- # Open and click tracking Tracking tells you who opened an email and which links they clicked. You turn it on per sending domain, and every HTML email from that domain is tracked, whether it comes from the API, the SMTP relay, a broadcast or an automation. Opens and clicks show up on the email in the dashboard, in [Metrics](https://hatang.dev/docs/guides/email/metrics-and-logs.md) and as `email.opened` and `email.clicked` webhooks. ## Before you start - A domain added to Ha Tang. See [Domains](https://hatang.dev/docs/guides/email/domains.md). - For the API, a key with `email:write`. ## Turn tracking on In the dashboard, open **Email → Domains**, pick the domain and switch **Open tracking** and **Click tracking** under **Tracking**. You can also set them when you add the domain (**Advanced options → Tracking options**). Both are off by default. **cURL** ```bash curl -X PATCH https://api.hatang.dev/domains/$DOMAIN_ID \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "open_tracking": true, "click_tracking": true }' ``` **Node.js** ```ts await hatang.domains.update({ id: domainId, open_tracking: true, click_tracking: true }); ``` **Python** ```python requests.patch( f"https://api.hatang.dev/domains/{domain_id}", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={"open_tracking": True, "click_tracking": True}, ) ``` **PHP** ```php $client->patch("/domains/$domainId", [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['open_tracking' => true, 'click_tracking' => true], ]); ``` The setting is copied onto each email when Ha Tang accepts it, so a change applies to emails sent from then on. Tracking cannot be switched per email. ## How open tracking works Ha Tang adds a 1×1 transparent image at the end of the HTML body. When a mail client loads it, Ha Tang records an open for that recipient. - Only the HTML part is tracked. A plain-text-only email cannot record opens. - Every open is stored. The `email.opened` webhook is sent at most once an hour per recipient. - Opens are an estimate. Some mail clients and security gateways load images without anyone reading the email (Apple Mail Privacy Protection, for example), and others block images, so the real number can be higher or lower. Ha Tang marks opens from known image proxies with `suspected_proxy` in the event data. ## How click tracking works Ha Tang rewrites each `http` or `https` link in the HTML to a tracking address. A click records the event and redirects the recipient (`302`) to the original URL. - Links that are not `http(s)` are left alone: `mailto:`, `tel:`, `sms:`, `#anchors`. - To leave one link untouched, add the `data-no-track` attribute: ``. - The original URL is encrypted inside the tracking link, so nobody can change it to redirect to another site. - Each click is stored and sends an `email.clicked` webhook with the link: ```json { "type": "email.clicked", "created_at": "2026-10-14T09:20:11.000Z", "data": { "email_id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10", "from": "Acme ", "to": ["lan@example.net"], "subject": "Order #1024 is confirmed", "created_at": "2026-10-14T09:12:03.000Z", "tags": {}, "click": { "link": "https://example.com/orders/1024", "timestamp": "2026-10-14T09:20:11.000Z", "ipAddress": "203.0.113.7", "userAgent": "Mozilla/5.0 …" } } } ``` The email page in the dashboard (**Engagement**) shows unique and total opens, and the clicks per link. ## Use your own tracking subdomain By default tracked links and the open pixel use the platform's tracking host. A tracking subdomain such as `links.example.com` makes them use your domain instead, so links in your emails match your brand and do not share a host with other senders. 1. Set the subdomain label: when you add the domain (**Tracking Subdomain**), or later with `PATCH /domains/{id}` and `{ "tracking_subdomain": "links" }`. The label must differ from the return-path label (`send`). 2. Publish the **Tracking** CNAME record shown on the domain page, for example `links.example.com CNAME` to the tracking host it lists. 3. Verify the domain again (**Re-check DNS** or `POST /domains/{id}/verify`). Until the CNAME is verified, links keep using the platform host (the dashboard says so under **Tracking**). Once it is verified, `tracking_domain_verified` is `true` and new emails use `https://links.example.com/…`. Ha Tang gets the HTTPS certificate for the subdomain by itself. Set `tracking_subdomain` to `null` to go back to the platform host. ## Privacy - An open or click event stores the recipient, the time, the IP address and the user agent of the request. These show up in webhooks and in `GET /emails/{id}/events`. - Open tracking adds an image that a recipient's mail client loads from Ha Tang. Tell your recipients about tracking in your privacy policy, and get consent where the law requires it. - If you do not need it, leave tracking off for transactional mail such as password resets. - Links in password-reset or sign-in emails work through the redirect too. Add `data-no-track` to keep a sensitive URL out of the tracking link. ## Next steps - [Metrics and logs](https://hatang.dev/docs/guides/email/metrics-and-logs.md) - [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md) - [Domains](https://hatang.dev/docs/guides/email/domains.md) Source: https://hatang.dev/docs/guides/email/tracking --- # Use Ha Tang with AI coding agents Coding agents write better integrations when they read the current docs instead of guessing. Ha Tang gives them four things; use as many as your tools support. ## 1. The MCP server [`@hatang/mcp`](https://hatang.dev/docs/guides/sdks/mcp.md) lets the agent search these docs and read any page as Markdown, and, with an API key, call the API: send a test email, check a domain, create a VietQR payment and settle it in development, read the inbox. ```bash claude mcp add hatang --env HATANG_API_KEY=ht_... --env HATANG_ENVIRONMENT=development -- npx -y @hatang/mcp ``` Cursor, VS Code, Windsurf and Claude Desktop: see [MCP server](https://hatang.dev/docs/guides/sdks/mcp.md#install). ## 2. Rules for your project One short text tells the agent which SDK to use, where the key lives, how errors come back, and how to verify payments and webhooks. It comes in three shapes: | Agent | File | Install | |---|---|---| | Codex, Copilot, Jules, Zed, Aider and other agents that read `AGENTS.md`; Claude Code (`CLAUDE.md`) | [`AGENTS.md`](https://hatang.dev/docs/agents/AGENTS.md) | `curl -s https://hatang.dev/docs/agents/AGENTS.md >> AGENTS.md` | | Cursor | [`hatang.mdc`](https://hatang.dev/docs/agents/hatang.mdc) | `mkdir -p .cursor/rules && curl -so .cursor/rules/hatang.mdc https://hatang.dev/docs/agents/hatang.mdc` | | Claude Code (skill) | [`SKILL.md`](https://hatang.dev/docs/agents/SKILL.md) | `mkdir -p .claude/skills/hatang && curl -so .claude/skills/hatang/SKILL.md https://hatang.dev/docs/agents/SKILL.md` | | GitHub Copilot in VS Code | [`AGENTS.md`](https://hatang.dev/docs/agents/AGENTS.md) | `curl -s https://hatang.dev/docs/agents/AGENTS.md >> .github/copilot-instructions.md` | Commit the file so everyone on the team, and every agent run in CI, gets the same rules. In Claude Code, the [plugin](https://github.com/sota-labs/hatang-claude-plugin) installs the skill and the MCP server in one step: `/plugin marketplace add sota-labs/hatang-claude-plugin`, then `/plugin install hatang@hatang`. Starting a new app? The [Next.js starter](https://github.com/sota-labs/hatang-nextjs-starter) has email, VietQR payments, IPN, webhooks and the chat widget wired up, with the rules already in its `AGENTS.md`. ## 3. The docs as Markdown Agents that can fetch URLs need no setup: - [`/docs/llms.txt`](https://hatang.dev/docs/llms.txt): every guide and reference page with a one-line summary ([Vietnamese](https://hatang.dev/vi/docs/llms.txt)). - [`/docs/llms-full.txt`](https://hatang.dev/docs/llms-full.txt): the whole hub in one file, for tools that load documentation up front (Cursor's **@Docs**, for example). - Any page plus `.md`, such as [`/docs/guides/email/sending.md`](https://hatang.dev/docs/guides/email/sending.md). - [`/docs/openapi.json`](https://hatang.dev/docs/openapi.json): the REST API as OpenAPI 3.1. In Cursor, add `https://hatang.dev/docs/llms-full.txt` under **Settings → Indexing & Docs** and mention it with `@Docs`. ## 4. Prompts that work Name Ha Tang and the docs in the prompt, and say which environment to use: - "Add order confirmation emails with Ha Tang (`@hatang/sdk`). Read https://hatang.dev/docs/guides/email/sending.md first. The key is in `HATANG_API_KEY`." - "Add the Ha Tang chat widget to this Next.js app with `@hatang/chat-react`, and identify signed-in users." - "Accept VietQR payments with Ha Tang: create the payment on checkout, verify the IPN, and confirm with `payments.verify` before marking the order paid. Use the development environment." - "Move this project's email sending from Resend to Ha Tang, following https://hatang.dev/docs/guides/email/migrate-from-resend.md." ## Keep keys out of the agent's reach - Give the agent a key made for the purpose, with only the scopes it needs, and revoke it afterwards. See [Security](https://hatang.dev/docs/guides/getting-started/security.md). - Never paste a key into a prompt or a rule file; put it in the environment. - Keep Payments on `development` (`HATANG_ENVIRONMENT=development`) while the agent works. Source: https://hatang.dev/docs/guides/getting-started/ai-agents --- # API libraries You can call the Ha Tang REST API from any language with a plain HTTP client. This page lists the official libraries that make it easier, and shows how to generate a typed client from the OpenAPI spec for languages without an official SDK. ## Node.js: `@hatang/sdk` The official server SDK covers Email, Support, Products, CRM, Payments and e-invoices, with TypeScript types for every request and response. ```bash npm install @hatang/sdk ``` ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev", // environment: "development", // Payments only; sent as Hatang-Environment }); const { data, error } = await hatang.emails.send({ from: "Shop ", to: "customer@example.com", subject: "Welcome", html: "

Welcome to the shop!

", }); ``` What to know: - **Results, not exceptions.** Every method resolves to `{ data, error }` and never throws for API errors. `error` has the usual `statusCode`, `name` and `message` ([Errors](https://hatang.dev/docs/guides/getting-started/errors.md)). A network failure gives `statusCode: 0`. - **Configuration.** Pass the key and options to the constructor, or set `HATANG_API_KEY`, `HATANG_BASE_URL` and `HATANG_ENVIRONMENT`. Always set the base URL to `https://api.hatang.dev`. - **Namespaces** follow the API: `emails`, `batch`, `domains`, `templates`, `contacts`, `contactProperties`, `audiences`, `segments`, `topics`, `broadcasts`, `automations`, `events`, `apiKeys`, `webhooks`, `logs`, `usage`, `chat`, `payments` and `einvoices`. - **Idempotency.** Methods that create things take an `{ idempotencyKey }` option ([Idempotency](https://hatang.dev/docs/guides/getting-started/idempotency.md)). - **Webhook helpers.** `verifyWebhook` and `constructWebhookEvent` check webhook signatures; `verifyIpn` and `constructIpnEvent` check Payments IPN calls ([Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md)). - **React Email.** Pass a `react` element to `emails.send` and the SDK renders it to HTML and plain text (install `@react-email/render` and `react` alongside). The [Node.js SDK guide](https://hatang.dev/docs/guides/sdks/node.md) covers every namespace with examples. ## Chat SDKs for web and mobile The chat SDKs run in your visitors' browsers and apps. They connect to the chat gateway with a public site id (and an app key on mobile), never with an API key. | Platform | Package | |---|---| | Website | `https://chat.hatang.dev/widget.js` | | React / Next.js | `@hatang/chat-react` | | WordPress | Ha Tang Chat plugin | | Flutter | `hatang_chat` | | React Native / Expo | `@hatang/chat-react-native` | | iOS (Swift) | `HatangChat` | | Android (Kotlin) | `dev.hatang:chat` | The [SDK overview](https://hatang.dev/docs/guides/sdks/overview.md) links the guide for each, the supported versions and the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md) they share. ## Other languages There is no official server SDK for PHP, Python or Go yet. Two good options: - **Call the REST API directly.** Every guide in this hub has cURL, Python and PHP samples. The API is plain JSON over HTTPS with a Bearer token. - **Generate a client** from the OpenAPI spec, as below. ## The OpenAPI spec The REST API is described by an OpenAPI 3.1 document, generated from the same code that serves the API: | Spec | URL | |---|---| | REST API | `https://api.hatang.dev/openapi.json` | | Chat gateway | `https://chat.hatang.dev/openapi.json` | Browse it in the [REST API reference](https://hatang.dev/docs/reference/api.md) and the [chat gateway reference](https://hatang.dev/docs/reference/chat-gateway.md), or import it into an API client such as Postman or Insomnia to try calls by hand. ### Generate a client Point a generator at the spec URL. Some examples: ```bash # TypeScript types (use them with openapi-fetch or your own fetch wrapper) npx openapi-typescript https://api.hatang.dev/openapi.json -o hatang.d.ts # Python pipx run openapi-python-client generate --url https://api.hatang.dev/openapi.json # Many languages, including PHP, Java, Kotlin, C# and Ruby npx @openapitools/openapi-generator-cli generate \ -i https://api.hatang.dev/openapi.json -g php -o ./hatang-php ``` > **Note** > > The spec uses OpenAPI 3.1. Check that your generator supports 3.1; some only handle 3.0 fully. Regenerate the client when you upgrade, as new endpoints and fields are added to the spec as they ship. Generated clients send what you give them, so remember the shared rules: the `Authorization: Bearer` header, `Idempotency-Key` on sends and payments, [pagination](https://hatang.dev/docs/guides/getting-started/pagination.md) with `after`, and retries on `429` ([Rate limits](https://hatang.dev/docs/guides/getting-started/rate-limits.md)). ## Next steps - [Node.js SDK](https://hatang.dev/docs/guides/sdks/node.md) - [SDK overview](https://hatang.dev/docs/guides/sdks/overview.md) - [REST API reference](https://hatang.dev/docs/reference/api.md) Source: https://hatang.dev/docs/guides/getting-started/api-libraries --- # Authentication Every call to the REST API at `https://api.hatang.dev` is authenticated with an **API key** sent as a Bearer token. This page shows how to create keys, which scopes to give them, how to restrict them, and what the API answers when a key is missing, wrong or not allowed. ## Before you start You need to be an owner or admin of the business, or a member with **Developer** access, to create and revoke keys ([Team and roles](https://hatang.dev/help/account/employees-and-roles)). ## Create an API key 1. In the dashboard, open **Settings → API Keys**. Email users also find the same page under **Email → API Keys**. 2. Click **Create key** and give the key a name that says where it runs, such as `production-web` or `erp-sync`. 3. Under **What this key can do**, tick the scopes it needs. Only the modules that are turned on for the business are listed. 4. Optionally restrict the key to one **Sending domain** or to **Browser origins** (see [Restricted keys](#restricted-keys)). 5. Click **Create key** and copy the key. It starts with `ht_` and is **shown only once**: Ha Tang stores only a hash of it. The keys table shows each key's prefix, what it can do and when it was **Last used**. ## Send the key Put the key in the `Authorization` header of every request: ```http Authorization: Bearer ht_123... ``` **cURL** ```bash curl "https://api.hatang.dev/emails?limit=1" \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```js import { Hatang } from "@hatang/sdk"; // The SDK sends the Authorization header for you. const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data, error } = await hatang.emails.list({ limit: 1 }); ``` **Python** ```python import os from hatang import Hatang # The SDK sends the Authorization header for you. hatang = Hatang(os.environ["HATANG_API_KEY"], base_url="https://api.hatang.dev") emails = hatang.emails.list(limit=1) ``` **PHP** ```php $client = new GuzzleHttp\Client([ 'base_uri' => 'https://api.hatang.dev', 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], ]); $res = $client->get('/emails', ['query' => ['limit' => 1]]); ``` The same key also works as the SMTP password for `smtp.hatang.dev:5870` (STARTTLS), with the username `hatang`. > **Danger: Server side only** > > An API key acts on behalf of your whole business. Keep it in an environment variable or a secret manager on your server. Never put it in browser JavaScript, a mobile app or a Git repository. Browsers and apps use the chat widget and the mobile SDKs, which have their own public site id and app keys. ## Scopes {#scopes} A scope has the form `:`, `:*` or `*`. | Module | Scopes | What they allow | |---|---|---| | Email | `email:send` | Send email: `POST /emails`, `POST /emails/batch` and the SMTP relay | | | `email:read` | List and fetch emails, metrics and received mail | | | `email:write` | Manage domains, templates, contacts, audiences, segments, topics, broadcasts and automations | | Support | `chat:read`, `chat:write` | Conversations, messages, visitors, tickets and widget sites; `write` also replies, assigns and resolves | | Payments | `payments:read`, `payments:write` | Payments, transactions, bank connections and e-invoices; `write` also creates payments and issues invoices | | Products | `products:read`, `products:write` | Products, categories and orders, including an order's payment link | | CRM | `crm:read`, `crm:write` | Customers, custom fields, merging, CSV export and ZNS | | Everything | `*` | Full access to every module | How scopes combine: - `write` and `send` both include `read`. A key with `email:send` can read the emails it sent. - `:*` covers every verb of that product, and `*` covers every product. - Products and CRM grew out of Support: a key with `chat:read`, `chat:write` or `chat:*` keeps working on the Products and CRM endpoints. New keys can use `products:*` and `crm:*` to narrow access. - A few endpoints belong to the whole business rather than a module. Managing webhook endpoints, reading API **Logs** and reading **Usage** need any `write` scope; managing API keys through the API needs `email:write`. > **Info: Received mail** > > Reading received mail (`/emails/receiving`) needs `email:read` or `email:write` granted on the key. The read access that `email:send` implies is not enough there. The dashboard offers the scopes of Email, Support and Payments. To create a key with `products:*`, `crm:*` or `*`, call `POST /api-keys` with a key that has `email:write`: ```bash curl -X POST https://api.hatang.dev/api-keys \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "erp-sync", "scopes": ["products:*", "crm:read"] }' ``` ```json { "id": "key_123...", "token": "ht_123..." } ``` A key needs the right scope **and** its module must be turned on. A call to a module that is off answers `403 product_disabled` even with the right scope ([Businesses and modules](https://hatang.dev/help/getting-started/modules)). ## Restricted keys {#restricted-keys} Two optional restrictions limit the damage if a key leaks. | Restriction | Where to set it | Effect | |---|---|---| | **Sending domain** | Dashboard, or `domain_id` on `POST /api-keys` | The key can only send from that verified domain. Sending from another domain fails with `401 restricted_api_key`. Only applies to keys with an Email scope. | | **Browser origins** | Dashboard: **Where will this key run?** → **In the browser**, then one origin per line, such as `https://shop.vn` | Requests must carry an `Origin` header from the list. Anything else, **including server requests without an `Origin` header**, fails with `401 restricted_api_key`. | Choose **On your server** (the default) for keys used from your backend; such a key has no origin lock. Choose **In the browser** only for a key that a browser loads. The error message says when a browser-only key was used without an `Origin` header. ## Test keys and the Payments environment Only the Payments module has environments. Every business has a **development** environment, with a simulated bank and the e-invoice provider's sandbox, and a **production** environment with real money. Payments data never crosses between them. Email, Support, Products and CRM have no environments. Pick the environment per request with the `Hatang-Environment` header: ```bash curl https://api.hatang.dev/payments \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" ``` | Key | No header | `Hatang-Environment: development` | `Hatang-Environment: production` | |---|---|---|---| | `ht_...` | production | development | production | | `ht_test_...` | development | development | `403 environment_mismatch` | - Every response repeats the environment it used in the `Hatang-Environment` header. - Any other header value fails with `422 validation_error`. - In the Node SDK, pass `environment: "development"` to the `Hatang` constructor, or set `HATANG_ENVIRONMENT`. - Keys that start with `ht_test_` are development keys from earlier versions of Ha Tang. They can never reach production data. New keys start with `ht_` and use the header. See [Payments environments](https://hatang.dev/docs/guides/payments/environments.md) for what differs between the two. ## Rotate and revoke keys Rotate a key when someone who knew it leaves, when it may have leaked, or on a regular schedule: 1. Create a new key with the same scopes and restrictions. 2. Deploy it everywhere the old key was used. 3. Watch the old key's **Last used** column until it stops changing. 4. Click **Revoke** on the old key, or call `DELETE /api-keys/{id}`. A revoked key stops working within a minute. Revoking cannot be undone; create a new key instead. ## Errors | Status | `name` | When | |---|---|---| | 401 | `missing_api_key` | No `Authorization: Bearer` header | | 403 | `invalid_api_key` | The key is malformed, unknown or revoked | | 401 | `restricted_api_key` | The key lacks the scope, is used from an origin it does not allow, or sends from a domain it is not locked to | | 403 | `product_disabled` | The module behind the endpoint is turned off for the business | | 403 | `environment_mismatch` | A `ht_test_` key asked for production, or a Payments action does not exist in this environment | | 422 | `validation_error` | `Hatang-Environment` is not `development` or `production` | ```json { "statusCode": 401, "name": "restricted_api_key", "message": "This API key does not have the payments:write scope" } ``` Branch on `name`, not on `message`. The message is for people and may change. None of these errors go away by retrying: fix the key, the scope or the module first. See [Errors](https://hatang.dev/docs/guides/getting-started/errors.md) for the full picture. ## Next steps - [Errors](https://hatang.dev/docs/guides/getting-started/errors.md) - [Rate limits](https://hatang.dev/docs/guides/getting-started/rate-limits.md) - [Security](https://hatang.dev/docs/guides/getting-started/security.md) - [Payments environments](https://hatang.dev/docs/guides/payments/environments.md) Source: https://hatang.dev/docs/guides/getting-started/authentication --- # Errors Every Ha Tang API, from Email to Payments to the chat gateway, reports errors in the same JSON shape with a conventional HTTP status. This page explains that shape, lists the errors you are most likely to meet with what to do about each, and shows how to handle them in code. The [error reference](https://hatang.dev/docs/reference/errors.md) lists every code. ## The error envelope A failed request answers with a `4xx` or `5xx` status and a body like this: ```json { "statusCode": 422, "name": "validation_error", "message": "to: Invalid email address" } ``` | Field | Type | Meaning | |---|---|---| | `statusCode` | number | The HTTP status, repeated in the body | | `name` | string | A stable, machine-readable code. **Branch on this.** | | `message` | string | An English sentence for people and logs. It may change at any time; do not parse it. | For validation errors, `message` names the first field that failed, in the form `field: problem`. ## Status codes | Status | Meaning | Retry? | |---|---|---| | `400` | The request conflicts with an earlier one (idempotency) | No, fix the request | | `401` | No API key, or the key is not allowed to do this | No | | `403` | The key is invalid, the module is off, or the action is not allowed in this state | No | | `404` | The resource does not exist, or belongs to another business or environment | No | | `409` | Conflict with the current state (a duplicate, a closed conversation, a concurrent request) | Sometimes, after reading the current state | | `413` | The uploaded file is too large | No | | `422` | The request body or query is invalid | No, fix the request | | `429` | Rate limit or daily quota reached | Yes, after a delay (rate limit only) | | `500`, `503` | Something failed on our side or at a provider | Yes, with backoff | ## Common errors | `name` | Status | What it means | What to do | |---|---|---|---| | `validation_error` | 422 | A field is missing or invalid, or the body is not valid JSON | Read `message`, fix the request. Do not retry as is. | | `validation_error` | 403 | Email only: the `from` domain is not verified | [Verify the domain](https://hatang.dev/docs/guides/email/domains.md) | | `missing_api_key` | 401 | No `Authorization: Bearer` header | Send the key | | `invalid_api_key` | 403 | The key is malformed, unknown or revoked | Check the key; create a new one if it was revoked | | `restricted_api_key` | 401 | The key lacks the scope, or its domain or origin restriction forbids this | Use a key with the right [scopes](https://hatang.dev/docs/guides/getting-started/authentication.md#scopes) | | `product_disabled` | 403 | The module is turned off for the business | Ask an owner or admin to turn it on; do not retry | | `environment_mismatch` | 403 | A Payments action or key does not match the environment | Check `Hatang-Environment` and the key | | `not_found` | 404 | No such resource for this business (and environment) | Check the id; it may belong to another business or environment | | `invalid_idempotent_request` | 400 | The `Idempotency-Key` was used before with a different body | Use a new key for a new request ([Idempotency](https://hatang.dev/docs/guides/getting-started/idempotency.md)) | | `concurrent_idempotent_requests` | 409 | Two requests with the same `Idempotency-Key` ran at the same time | Retry after a moment; you get the first result | | `rate_limit_exceeded` | 429 | Too many requests per second for this module | Wait `retry-after` seconds and retry ([Rate limits](https://hatang.dev/docs/guides/getting-started/rate-limits.md)) | | `daily_quota_exceeded` | 429 | The business reached its daily email quota | Wait for midnight UTC or raise the quota | | `conversation_closed` | 409 | The chat conversation is closed | Reopen it, or start a new conversation | | `file_too_large`, `unsupported_file_type` | 413, 422 | An uploaded file is too big or of a type that is not accepted | Check the [limits](https://hatang.dev/docs/reference/limits.md) | | `invalid_state` | 409 | The object cannot do that now, such as canceling a paid payment | Fetch the object and check its status | | `application_error` | 500 or 403 | An unexpected failure; also `403` when email sending is paused for the business | Retry a `500` with backoff; for `403`, resume sending in **Email → Email settings** | | `internal_server_error` | 500 | Something broke on our side | Retry with backoff; contact support if it persists | | `provider_unavailable` | 503 | A bank or e-invoice provider did not answer | Retry later | ## Handle errors in code Decide what to do from `name` and `statusCode`: retry what is temporary, surface what a person must fix, and log the rest with the request. **Node.js** The Node SDK never throws for API errors. Every method resolves to `{ data, error }`, where `error` has the same `statusCode`, `name` and `message`. When the API cannot be reached at all, `error.statusCode` is `0` and `error.name` is `application_error`. ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data, error } = await hatang.emails.send({ from, to, subject, html }, { idempotencyKey: orderId }); if (error) { switch (error.name) { case "validation_error": // Our bug or bad input: log it and fix the payload. logger.error({ error }, "Hatang rejected the email"); break; case "rate_limit_exceeded": case "internal_server_error": case "application_error": // Temporary: retry later with the same idempotency key. await queue.retryLater({ orderId }); break; case "product_disabled": case "restricted_api_key": case "invalid_api_key": // Configuration: alert a human, do not retry. await alertOps(error.message); break; default: logger.error({ error }, "Unexpected Hatang error"); } } ``` **Python** ```python from hatang import Hatang, HatangError hatang = Hatang() # reads HATANG_API_KEY try: hatang.emails.send(payload, idempotency_key=order_id) except HatangError as err: if err.retryable: # rate limited, 5xx or unreachable (status_code 0) retry_later(order_id) # temporary elif err.name in ("product_disabled", "restricted_api_key", "invalid_api_key"): alert_ops(err.message) # configuration else: raise ValueError(f"{err.name}: {err.message}") # fix the request ``` **PHP** ```php $res = $client->post('/emails', [ 'headers' => ['Idempotency-Key' => $orderId], 'json' => $payload, 'http_errors' => false, ]); if ($res->getStatusCode() >= 400) { $err = json_decode((string) $res->getBody(), true); switch ($err['name'] ?? '') { case 'rate_limit_exceeded': retryLater($orderId); break; case 'product_disabled': case 'restricted_api_key': case 'invalid_api_key': alertOps($err['message']); break; default: if ($res->getStatusCode() >= 500) { retryLater($orderId); } else { throw new RuntimeException($err['name'] . ': ' . $err['message']); } } } ``` > **Tip: Find failed calls** > > **Email → Logs** in the dashboard lists every API request of the business with its status, duration and the request and response bodies. Look for the calls with a `4xx` or `5xx` status to see the exact error each one returned. ## Next steps - [Error reference](https://hatang.dev/docs/reference/errors.md): every error code - [Rate limits](https://hatang.dev/docs/guides/getting-started/rate-limits.md) - [Idempotency](https://hatang.dev/docs/guides/getting-started/idempotency.md) Source: https://hatang.dev/docs/guides/getting-started/errors --- # Idempotency A network error or a timeout leaves you not knowing whether a request went through. If you simply send it again, a customer may get the same email twice or be asked to pay twice. An **idempotency key** makes the retry safe: Ha Tang does the work once and answers every repeat with the same result. ## Where it is supported Send the key in the `Idempotency-Key` header. It is any string you choose, up to 256 characters. | Endpoint | Scope of the key | A repeat with the same body | A repeat with a different body | |---|---|---|---| | `POST /emails` | The business | `200` with the same `id`; nothing is sent again | `400 invalid_idempotent_request` | | `POST /payments` | The business and the Payments environment | `201` with the same payment | `400 invalid_idempotent_request` | | `POST /chat/conversations/{id}/messages` | The conversation | `200` with the same message and `"duplicate": true` | `200` with the first message (the body is not compared) | `POST /emails/batch` and other endpoints do not take an idempotency key. ## Send an idempotency key Use a key that identifies the business operation, not the HTTP attempt: your order id, `welcome-`, `invoice--reminder-1`. Generate it once, store it with the job, and reuse it on every retry. **cURL** ```bash curl -X POST https://api.hatang.dev/emails \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1001-confirmation" \ -d '{ "from": "Shop ", "to": "customer@example.com", "subject": "Order #1001 confirmed", "html": "

Thanks for your order!

" }' ``` **Node.js** ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); // Email await hatang.emails.send( { from: "Shop ", to: "customer@example.com", subject: "Order #1001 confirmed", html: "

Thanks!

" }, { idempotencyKey: "order-1001-confirmation" }, ); // Payment await hatang.payments.create({ amount: 150000, description: "Order 1001" }, { idempotencyKey: "order-1001" }); // Chat reply await hatang.chat.conversations.send("cnv_123...", { content: "Your order ships today." }, { idempotencyKey: "order-1001-shipped" }); ``` **Python** ```python from hatang import Hatang hatang = Hatang() # reads HATANG_API_KEY hatang.emails.send( { "from": "Shop ", "to": "customer@example.com", "subject": "Order #1001 confirmed", "html": "

Thanks for your order!

", }, idempotency_key="order-1001-confirmation", ) ``` **PHP** ```php $res = $client->post('/emails', [ 'headers' => ['Idempotency-Key' => 'order-1001-confirmation'], 'json' => [ 'from' => 'Shop ', 'to' => 'customer@example.com', 'subject' => 'Order #1001 confirmed', 'html' => '

Thanks for your order!

', ], ]); ``` ## How keys are stored - **Email and payments.** The key is stored with the email or payment it created, together with a hash of the request body. A repeat with the same key is compared against that hash. Keys do not expire: as long as the email or payment exists, its key keeps pointing to it. A replayed email does not count against the daily quota again. - **Chat messages.** The key becomes the message's `client_id`. A repeat in the same conversation returns the existing message with `"duplicate": true`. The widget and the mobile SDKs do the same thing with the `client_id` they send on each message, which is why a message sent twice over a flaky connection appears once in the inbox. - **Payments environments.** A payment key is scoped to its environment, so the same order id can be used once in development and once in production. ## Conflicts | `name` | Status | Cause | What to do | |---|---|---|---| | `invalid_idempotent_request` | 400 | The key was already used with a **different** request body | You reused a key for a different operation. Generate a new key; never change the body of a retry. | | `concurrent_idempotent_requests` | 409 | Email only: two requests with the same key arrived at the same moment | Wait a moment and retry with the same key; you get the result of the first one. | ```json { "statusCode": 400, "name": "invalid_idempotent_request", "message": "Same idempotency key used with a different request payload" } ``` "Different body" means any changed value, such as a new timestamp or a regenerated random id inside the payload. Build the payload once, store it with the key, and resend the same payload on every retry. ## Retry advice 1. **Create the key before the first attempt** and keep it with the job, so a crash between attempts does not lose it. 2. **Retry only what is temporary:** network errors, `429 rate_limit_exceeded` and `5xx`. Use exponential backoff and honor `retry-after` ([Rate limits](https://hatang.dev/docs/guides/getting-started/rate-limits.md)). 3. **Do not retry `4xx` errors** other than `429` and `409 concurrent_idempotent_requests`; fix the request instead. 4. **Treat a replay as success.** A `200` for a repeated email or a `"duplicate": true` chat message means the first attempt worked. 5. **Make your webhook handlers idempotent too.** Deliveries can repeat; see [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md#make-your-handler-idempotent). ## Next steps - [Rate limits](https://hatang.dev/docs/guides/getting-started/rate-limits.md) - [Errors](https://hatang.dev/docs/guides/getting-started/errors.md) - [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md) Source: https://hatang.dev/docs/guides/getting-started/idempotency --- # Introduction Ha Tang is a developer platform for businesses in Vietnam, built from **modules**: transactional email, customer support chat, bank-transfer payments, a product catalogue and a CRM. Each business turns on the modules it needs. They all share one account, one team, one set of API keys and one stream of webhooks, so a single integration covers everything you use. ## The modules | Module | What it does | Start here | |---|---|---| | **Email** | Resend-compatible email API and SMTP relay, several sending domains, templates, audiences, broadcasts, automations and inbound mail | [Email overview](https://hatang.dev/docs/guides/email/overview.md) | | **Support** (Customer Support) | Website chat widget, native SDKs for iOS, Android, Flutter and React Native, one inbox for Zalo (OA and personal), Messenger, Instagram, TikTok, TikTok Shop, WhatsApp, Telegram, LINE, X and email, plus tickets and an AI assistant | [Support overview](https://hatang.dev/docs/guides/support/overview.md) | | **Payments** | VietQR transfers straight into your own bank account, automatic matching and e-invoices | [Payments overview](https://hatang.dev/docs/guides/payments/overview.md) | | **Products** | Product catalogue and orders, product cards and payment links sent inside a conversation | [Support REST API](https://hatang.dev/docs/guides/support/rest-api.md) | | **CRM** | One customer profile across every channel, custom fields, filters by stage, label and channel, and Zalo ZNS messages | [Support REST API](https://hatang.dev/docs/guides/support/rest-api.md) | There is no "website" or "sales channel" layer above the modules. Each module owns its own resources and can have many of them: Email has several sending domains, and Support has several widget sites and mobile apps. An owner or admin turns modules on and off in **Business settings → Modules**. See [Businesses and modules](https://hatang.dev/help/getting-started/modules). ## One API key, one webhook stream Every module is served by the same REST API at `https://api.hatang.dev`, with the same conventions: - **One kind of API key.** A key starts with `ht_` and carries [scopes](https://hatang.dev/docs/guides/getting-started/authentication.md) such as `email:send` or `chat:read`. Send it as `Authorization: Bearer ht_123...`. - **One error format.** Every error is a JSON object `{ statusCode, name, message }`. See [Errors](https://hatang.dev/docs/guides/getting-started/errors.md). - **One webhook stream.** A webhook endpoint subscribes to events from any module (`email.delivered`, `chat.message.created`, `payment.succeeded`, …) and every delivery is signed the same way. See [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md). - **Shared rules** for [rate limits](https://hatang.dev/docs/guides/getting-started/rate-limits.md), [pagination](https://hatang.dev/docs/guides/getting-started/pagination.md) and [idempotency](https://hatang.dev/docs/guides/getting-started/idempotency.md). Two more hosts serve specific clients: | Host | Used by | |---|---| | `https://api.hatang.dev` | Your server: REST API for every module | | `https://chat.hatang.dev` | The chat widget and the mobile chat SDKs (realtime gateway) | | `smtp.hatang.dev:5870` (STARTTLS) | Apps that send email over SMTP | | `https://app.hatang.dev` | The dashboard, for you and your team | ## How to use this hub The hub has two parts: - **Guides** (this part) explain how to do something end to end: send your first email, put the chat widget on a site, take a payment, verify a webhook. Start with **Getting started**, then open the guides of the modules you use. - **References** list every detail: the [REST API reference](https://hatang.dev/docs/reference/api.md), the [chat gateway reference](https://hatang.dev/docs/reference/chat-gateway.md), the [webhook events](https://hatang.dev/docs/reference/webhooks.md), the [error codes](https://hatang.dev/docs/reference/errors.md), the [limits](https://hatang.dev/docs/reference/limits.md) and the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md). Code samples in the guides come in cURL, Node.js (with [`@hatang/sdk`](https://hatang.dev/docs/guides/getting-started/api-libraries.md)), Python and PHP. Pick a language once and every page follows it. ## Integration checklist 1. Create an account at [app.hatang.dev](https://app.hatang.dev) and pick the modules you need. 2. Create an API key with the scopes of those modules ([Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md)). 3. Call the API at `https://api.hatang.dev`, or use the Node SDK `@hatang/sdk`. 4. Add a [webhook endpoint](https://hatang.dev/docs/guides/getting-started/webhooks.md) to hear about events. 5. Invite your team and give each person the access they need ([Team and roles](https://hatang.dev/help/account/employees-and-roles)). ## Next steps - [Quickstart: send your first email](https://hatang.dev/docs/guides/getting-started/quickstart-email.md) - [Quickstart: add live chat to your site](https://hatang.dev/docs/guides/getting-started/quickstart-chat.md) - [Payments quickstart](https://hatang.dev/docs/guides/payments/quickstart.md) - [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md) Source: https://hatang.dev/docs/guides/getting-started/introduction --- # Pagination List endpoints return their results one page at a time, newest first, using **keyset pagination**: you ask for the page that comes after the last item you have. Unlike page numbers, this stays correct while new items keep arriving. This page explains the parameters, the response fields and how to walk a whole list. ## Parameters | Parameter | Meaning | |---|---| | `limit` | How many items to return. The default and the maximum depend on the list (see below). A value out of range fails with `422 validation_error`. | | `after` | The `id` of the last item of the previous page. The response starts right after it. Leave it out for the first page. | `GET /emails` also takes `before`, the `id` of an item, to fetch the items newer than it. ## Response Every list answers with the same envelope: ```json { "object": "list", "has_more": true, "next_cursor": "pay_01a0b904297873e8b3524a26fdca0ab5", "data": [ { "object": "payment", "id": "pay_01a0b904...", "status": "paid" }, { "object": "payment", "id": "pay_01a0b903...", "status": "pending" } ] } ``` | Field | Meaning | |---|---| | `data` | The items of this page | | `has_more` | `true` when there is at least one more page | | `next_cursor` | Payments lists only: the value to pass as `after` for the next page, or `null` on the last page. It is the `id` of the last item in `data`. | On lists without `next_cursor`, pass the `id` of the last item in `data` as `after`. The result is the same. ## Limits per list | Lists | Default `limit` | Maximum | |---|---|---| | Email: emails, received emails, contacts, templates, logs | 20 | 100 | | Payments: payments, transactions, e-invoices | 20 | 100 | | Support: conversations | 50 | 100 | | Support, Products and CRM: tickets, products, orders, customers | 50 | 200 | A few small lists, such as domains, webhooks and broadcasts, return every item in one response. Conversation messages page differently, by sequence number: `GET /chat/conversations/{id}/messages` takes `after_seq` (the `seq` of the last message you have) and a `limit` of up to 200, 100 by default. That is how you sync a conversation incrementally. ## Walk through a whole list Fetch a page, process it, and keep going while `has_more` is `true`: **cURL** ```bash # First page curl "https://api.hatang.dev/payments?limit=100" \ -H "Authorization: Bearer $HATANG_API_KEY" # Next page: pass next_cursor (or the last id) as after curl "https://api.hatang.dev/payments?limit=100&after=pay_01a0b904297873e8b3524a26fdca0ab5" \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); async function* allEmails() { let after; while (true) { const { data: page, error } = await hatang.emails.list({ limit: 100, after }); if (error) throw new Error(`${error.name}: ${error.message}`); yield* page.data; if (!page.has_more || page.data.length === 0) return; after = page.data[page.data.length - 1].id; } } for await (const email of allEmails()) { console.log(email.id, email.last_event); } ``` **Python** ```python from hatang import Hatang hatang = Hatang() # reads HATANG_API_KEY def all_payments(): after = None while True: page = hatang.payments.list(limit=100, after=after) yield from page["data"] if not page["has_more"]: return after = page.get("next_cursor") or page["data"][-1]["id"] for payment in all_payments(): print(payment["id"], payment["status"]) ``` **PHP** ```php function allConversations(GuzzleHttp\Client $client): Generator { $query = ['limit' => 100]; do { $page = json_decode((string) $client->get('/chat/conversations', ['query' => $query])->getBody(), true); yield from $page['data']; $last = end($page['data']); $query['after'] = $last ? $last['id'] : null; } while ($page['has_more'] && $last); } foreach (allConversations($client) as $conversation) { echo $conversation['id'], PHP_EOL; } ``` > **Tip** > > Filters such as `status` stay the same on every page: send them with each request together with `after`. Each page is one request against the [rate limit](https://hatang.dev/docs/guides/getting-started/rate-limits.md), so use the largest `limit` you can handle. ## Next steps - [Rate limits](https://hatang.dev/docs/guides/getting-started/rate-limits.md) - [Errors](https://hatang.dev/docs/guides/getting-started/errors.md) - [REST API reference](https://hatang.dev/docs/reference/api.md) Source: https://hatang.dev/docs/guides/getting-started/pagination --- # Quickstart: add live chat to your site This guide puts a chat bubble on your website, tells the inbox who your signed-in users are, and has you answer the first message from the dashboard, the Ha Tang mobile apps or your own server. At the end you add the same chat to your iOS, Android, Flutter or React Native app. ## Before you start - A Ha Tang account at [app.hatang.dev](https://app.hatang.dev) with the **Support** module turned on ([Businesses and modules](https://hatang.dev/help/getting-started/modules)). - A website you can edit, with access to its HTML layout or a tag manager. - To change Support settings you need to be an owner or admin, or a member with **Manage** access to Support ([Team and roles](https://hatang.dev/help/account/employees-and-roles)). ## 1. Copy the snippet 1. In the dashboard, open **Support → Channels** and choose your website. To add another one, click **Add channel** and pick it under **Websites**. 2. In the **Install** card, click **Copy snippet**. The snippet is two lines. The first sets your site id (it starts with `site_`), the second loads the widget from the chat gateway: ```html ``` ## 2. Add it to your site Paste the snippet just before `` on every page, or in the layout that every page shares. The script loads asynchronously and does not block your page. Reload your site: the chat bubble appears in the corner. Back in the dashboard, the **Install** card switches to **Installed** and shows the host it was seen on. Using WordPress, Shopify, Haravan, LadiPage, Sapo, Google Tag Manager, Webflow or React? The [widget install guide](https://hatang.dev/docs/guides/support/web-widget/install.md) has steps for each, and the [WordPress plugin](https://hatang.dev/docs/guides/integrations/wordpress.md) needs no code at all. > **Note: Where the widget loads** > > The widget only starts on your website's domain and its subdomains. To test on `http://localhost:3000` or a preview deployment, add that origin under **Extra allowed origins** on the website's page. Other origins are refused with `origin_not_allowed`. ## 3. Identify signed-in users Without any code, every visitor is an anonymous guest. When a user is signed in on your site, pass their details to the widget so your team sees who they are talking to: ```html ``` `$hatang.push` works before and after the widget has loaded, so you can put this anywhere after the first line of the snippet. The `hash` proves that the user id came from your server and not from someone typing it in the browser console. Without it, the details are shown as **unverified** and are never merged with another customer's history. With a valid `hash`, the inbox treats the user id as **verified** and joins all their sessions, on every device, into one customer. Compute `hash` on your server as the HMAC-SHA256 of the user id, keyed with the site's **Identity secret** (on the website's page, **Rotate & reveal**), in lowercase hex: **Node.js** ```js import { createHmac } from "node:crypto"; export const hatangHash = (userId) => createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(String(userId)).digest("hex"); ``` **Python** ```python import os from hatang import identity_hash # pip install hatang def hatang_hash(user_id) -> str: return identity_hash(str(user_id), os.environ["HATANG_IDENTITY_SECRET"]) ``` **PHP** ```php $hash = hash_hmac('sha256', (string) $userId, getenv('HATANG_IDENTITY_SECRET')); ``` > **Danger: Keep the identity secret on your server** > > Anyone with the identity secret can sign in to the chat as any of your users. Never put it in page JavaScript, a mobile app or a public plugin; send only the computed `hash` to the browser. The [identity verification guide](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md) explains merging, secret rotation and the syntax for every SDK. The [JavaScript API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md) lists the other commands, such as `open`, `setLocale` and `on`. ## 4. Reply from the inbox Open your site in a private window and send a message from the widget. It arrives in **Support → Inbox** in real time. Type a reply and the visitor sees it at once in the widget. Your team can also answer from the Ha Tang apps for iPhone, Android and Mac, which receive push notifications for new messages. To answer from your own systems instead, use the REST API with a key that has the `chat:write` scope. List open conversations, then post a message as your team: **cURL** ```bash curl "https://api.hatang.dev/chat/conversations?status=open&limit=20" \ -H "Authorization: Bearer $HATANG_API_KEY" curl -X POST https://api.hatang.dev/chat/conversations/cnv_123.../messages \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reply-cnv_123-1" \ -d '{ "content": "Hi An, your order ships today." }' ``` **Node.js** ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data: open } = await hatang.chat.conversations.list({ status: "open", limit: 20 }); const conversation = open.data[0]; const { data, error } = await hatang.chat.conversations.send( conversation.id, { content: "Hi An, your order ships today." }, { idempotencyKey: `reply-${conversation.id}-1` }, ); ``` **Python** ```python from hatang import Hatang hatang = Hatang() # reads HATANG_API_KEY open_ = hatang.chat.conversations.list(status="open", limit=20) conversation_id = open_["data"][0]["id"] message = hatang.chat.conversations.send( conversation_id, {"content": "Hi An, your order ships today."}, idempotency_key=f"reply-{conversation_id}-1", ) print(message) ``` **PHP** ```php 'https://api.hatang.dev', 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], ]); $open = json_decode((string) $client->get('/chat/conversations', ['query' => ['status' => 'open', 'limit' => 20]])->getBody(), true); $conversationId = $open['data'][0]['id']; $res = $client->post("/chat/conversations/{$conversationId}/messages", [ 'headers' => ['Idempotency-Key' => "reply-{$conversationId}-1"], 'json' => ['content' => 'Hi An, your order ships today.'], ]); ``` The message call answers `201`: ```json { "object": "chat_message", "id": "msg_123...", "seq": 7, "conversation_id": "cnv_123...", "duplicate": false } ``` To react to new visitor messages without polling, subscribe a [webhook](https://hatang.dev/docs/guides/getting-started/webhooks.md) to `chat.message.created`. The [Support REST API guide](https://hatang.dev/docs/guides/support/rest-api.md) covers assigning, tagging, private notes, customers and orders. ## 5. Add chat to your mobile apps The native SDKs talk to the same gateway as the widget, so messages from your app land in the same inbox and a verified user keeps one history across web and mobile. 1. In **Support → Channels**, click **Add channel**, then **Mobile apps**, and **Create app key**. The key starts with `hak_` and is shown only once. 2. Install the SDK for your platform and configure it with your site id and the app key. 3. Call `identify` with the same user id and `hash` as on the web. 4. Optionally upload your Firebase credentials so the app receives [push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md). | Platform | Guide | |---|---| | Flutter | [Flutter SDK](https://hatang.dev/docs/guides/sdks/flutter.md) | | iOS (Swift) | [iOS SDK](https://hatang.dev/docs/guides/sdks/ios.md) | | Android (Kotlin) | [Android SDK](https://hatang.dev/docs/guides/sdks/android.md) | | React Native / Expo | [React Native SDK](https://hatang.dev/docs/guides/sdks/react-native.md) | | React / Next.js (web) | [React component](https://hatang.dev/docs/guides/sdks/react.md) | The app key is not a secret in the way an API key is (it ships inside your app), but you can revoke it and issue a new one at any time. Never ship an `ht_` API key or the identity secret in an app. ## What can go wrong | Symptom | Cause | Fix | |---|---|---| | No bubble, `origin_not_allowed` in the browser console | The page is not on the website's domain | Add the origin under **Extra allowed origins** | | No bubble, `product_disabled` | The Support module is turned off | Ask an owner or admin to turn it on | | No bubble on some pages | Display rules on the website's page hide it there | Check where the widget is set to show | | A user shows as unverified | `hash` is missing or does not match | Compute it from the exact user id string, with the current identity secret | | `invalid_app_key` in a mobile app | Wrong key, or a key of another site | Copy the app key of the right website again | ## Next steps - [Support overview](https://hatang.dev/docs/guides/support/overview.md): channels, routing, tickets and the AI assistant - [Widget JavaScript API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md) - [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md) - [SDK overview](https://hatang.dev/docs/guides/sdks/overview.md) Source: https://hatang.dev/docs/guides/getting-started/quickstart-chat --- # Quickstart: send your first email This guide takes you from a new account to a delivered email in about ten minutes. You create an API key, verify the domain you send from, send a message through the REST API and then find it in the dashboard. ## Before you start - A Ha Tang account at [app.hatang.dev](https://app.hatang.dev) with the **Email** module turned on ([Businesses and modules](https://hatang.dev/help/getting-started/modules)). - Access to the DNS settings of a domain you own, such as `shop.vn`. - To create keys you need to be an owner or admin, or a member with the **Developer** access ([Team and roles](https://hatang.dev/help/account/employees-and-roles)). ## 1. Create an API key 1. In the dashboard, open **Settings → API Keys** (the same page is also under **Email → API Keys**). 2. Click **Create key**, give it a name such as `production`, and tick **Send** under Email. That grants the `email:send` scope, which is all you need to send. 3. Optionally pick a **Sending domain** to lock the key to one domain. 4. Copy the key. It starts with `ht_` and is shown only once. Store it as an environment variable on your server: ```bash export HATANG_API_KEY="ht_123..." ``` > **Caution** > > API keys belong on your server. Never put one in browser JavaScript or a mobile app bundle. See [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md) for scopes and restricted keys. ## 2. Verify a sending domain Ha Tang only sends from domains you have proved you own. 1. Open **Email → Domains** and add your domain, for example `shop.vn`. 2. Add the DNS records Ha Tang shows you at your DNS provider. 3. Wait for the domain to show as **Verified**. The [Domains guide](https://hatang.dev/docs/guides/email/domains.md) explains each record, how long verification takes and how to fix a domain that stays pending. If you send from a domain that is not verified, the API answers `403` with `name: "validation_error"` and a message such as `The shop.vn domain is not verified. Please, add and verify your domain first.` ## 3. Send an email Send a `POST` request to `https://api.hatang.dev/emails`. The `from` address must be on your verified domain. `to` takes one address or a list. **cURL** ```bash curl -X POST https://api.hatang.dev/emails \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Shop ", "to": ["customer@example.com"], "subject": "Your order is confirmed", "html": "

Thanks for your order!

" }' ``` **Node.js** ```bash npm install @hatang/sdk ``` ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data, error } = await hatang.emails.send({ from: "Shop ", to: ["customer@example.com"], subject: "Your order is confirmed", html: "

Thanks for your order!

", }); if (error) { console.error(error.name, error.message); } else { console.log("Sent", data.id); } ``` **Python** ```python from hatang import Hatang, HatangError # pip install hatang hatang = Hatang() # reads HATANG_API_KEY try: email = hatang.emails.send({ "from": "Shop ", "to": ["customer@example.com"], "subject": "Your order is confirmed", "html": "

Thanks for your order!

", }) print("Sent", email["id"]) except HatangError as err: print(err.name, err.message) ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->post('/emails', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'from' => 'Shop ', 'to' => ['customer@example.com'], 'subject' => 'Your order is confirmed', 'html' => '

Thanks for your order!

', ], 'http_errors' => false, ]); $body = json_decode((string) $res->getBody(), true); if ($res->getStatusCode() === 200) { echo 'Sent ' . $body['id']; } else { echo $body['name'] . ': ' . $body['message']; } ``` A successful call answers `200` with the id of the new email: ```json { "id": "01a0b904-2978-73e8-b352-4a26fdca0ab5" } ``` The email is queued and delivered in the background. You need `html`, `text` or a published `template`; `to`, `cc` and `bcc` together take at most 50 addresses. > **Tip: Retries** > > Add an `Idempotency-Key` header (for example your order id) so a retried request never sends the same email twice. See [Idempotency](https://hatang.dev/docs/guides/getting-started/idempotency.md). > **Note: Node SDK base URL** > > Pass `baseUrl: "https://api.hatang.dev"` (or set the `HATANG_BASE_URL` environment variable) when you create the client, as in the example above. ## 4. Check it in the dashboard - **Email → Emails** lists every message with its status: queued, sent, delivered, bounced and so on. Open one to see each recipient and the delivery events. - **Email → Logs** lists every API request your keys made, with the status code, duration and request and response bodies. A failed call shows up here with its error. You can read the same status from the API: ```bash curl https://api.hatang.dev/emails/01a0b904-2978-73e8-b352-4a26fdca0ab5 \ -H "Authorization: Bearer $HATANG_API_KEY" ``` The response is shortened here: ```json { "object": "email", "id": "01a0b904-2978-73e8-b352-4a26fdca0ab5", "from": "Shop ", "to": ["customer@example.com"], "subject": "Your order is confirmed", "last_event": "delivered", "created_at": "2026-10-23T09:34:12.952Z" } ``` ## What can go wrong | Status | `name` | Cause | Fix | |---|---|---|---| | 401 | `missing_api_key` | No `Authorization` header | Send `Authorization: Bearer ht_...` | | 403 | `invalid_api_key` | The key is wrong or revoked | Copy the key again or create a new one | | 401 | `restricted_api_key` | The key is locked to a different sending domain | Use a key for this domain, or one for all domains | | 403 | `validation_error` | The `from` domain is not verified | Finish [domain verification](https://hatang.dev/docs/guides/email/domains.md) | | 422 | `validation_error` | A field is missing or invalid; `message` names it | Fix the request body | | 403 | `product_disabled` | The Email module is turned off | Ask an owner or admin to turn it on | | 429 | `rate_limit_exceeded` | More than 10 email requests per second | Back off and retry ([Rate limits](https://hatang.dev/docs/guides/getting-started/rate-limits.md)) | | 429 | `daily_quota_exceeded` | The business reached its daily email quota | Wait for midnight UTC or raise the quota in **Email → Email settings** | ## Next steps - [Get delivery events with webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md) - [Email overview](https://hatang.dev/docs/guides/email/overview.md): templates, broadcasts, audiences and inbound mail - [Metrics and logs](https://hatang.dev/docs/guides/email/metrics-and-logs.md) - [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md) Source: https://hatang.dev/docs/guides/getting-started/quickstart-email --- # Rate limits The REST API limits how many requests a business can make per second, separately for each module, and Email also has a daily sending quota. This page lists the limits, the headers that tell you where you stand, and how to retry when you hit one. ## Requests per second Limits count requests per **business** (not per API key) over a sliding one-second window. All keys of a business share the same budget. | Endpoints | Limit | |---|---| | Support, Products and CRM: everything under `/chat/` | 50 requests per second | | Payments: `/payments` and `/einvoices` | 20 requests per second | | Email and the rest: `/emails`, `/domains`, `/templates`, `/contacts`, `/audiences`, `/segments`, `/topics`, `/broadcasts`, `/automations`, `/events`, `/webhooks`, `/api-keys`, `/logs`, `/usage` | 10 requests per second | Each module has its own budget: a burst of email sends does not slow down your chat or payments calls. The chat gateway at `chat.hatang.dev`, which the widget and the mobile SDKs use, has its own limits on visitors and messages. See [Limits](https://hatang.dev/docs/reference/limits.md). ## Rate limit headers Every response from a rate-limited endpoint carries these headers: | Header | Meaning | |---|---| | `ratelimit-limit` | The number of requests allowed per window for this module | | `ratelimit-remaining` | Requests left in the current window | | `ratelimit-reset` | Seconds until the window resets (always `1`) | | `retry-after` | Only on a `429`: seconds to wait before retrying | ```http HTTP/1.1 429 Too Many Requests ratelimit-limit: 10 ratelimit-remaining: 0 ratelimit-reset: 1 retry-after: 1 content-type: application/json { "statusCode": 429, "name": "rate_limit_exceeded", "message": "Too many requests. You can only make 10 requests per second." } ``` ## Daily email quota On top of the per-second limit, each business has a **daily email quota**: the number of emails it can accept per day. Every accepted email counts once, whether it came from `POST /emails`, a batch, the SMTP relay or a broadcast. Email replies sent from the Support inbox do not count. The count resets at **midnight UTC**. Past the quota, sends fail with `429 daily_quota_exceeded`: ```json { "statusCode": 429, "name": "daily_quota_exceeded", "message": "You have reached your daily email sending quota of 10000. It resets at midnight UTC." } ``` Retrying does not help until the quota resets. You can see the quota and today's count in **Email → Email settings** (**Daily quota**), or with `GET /usage` (response shortened): ```json { "object": "usage", "emails": { "daily": { "used": 1520, "limit": 10000, "resets_at": "2026-10-24T00:00:00.000Z" } } } ``` A member with **Manage** access to Email can set a lower or higher quota for the business in Email settings, which is a useful safety net against a runaway loop. Leave the field empty to use the platform default. `GET /usage` needs a key with a `write` scope. ## Retry with backoff Retry `429 rate_limit_exceeded` and `5xx` errors, never `4xx` errors that come from the request itself. Wait for `retry-after` seconds when the header is present, otherwise back off exponentially, and add some random jitter so many workers do not retry in step. Combine retries with an [`Idempotency-Key`](https://hatang.dev/docs/guides/getting-started/idempotency.md) so a retried send never creates a duplicate. **Node.js** ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // statusCode 0 means the API could not be reached (network error). const retryable = (e) => e.name === "rate_limit_exceeded" || e.statusCode >= 500 || e.statusCode === 0; async function withRetry(call, { attempts = 5 } = {}) { for (let i = 0; ; i++) { const result = await call(); if (!result.error || !retryable(result.error) || i === attempts - 1) return result; const backoff = Math.min(1000 * 2 ** i, 30_000); await sleep(backoff + Math.random() * 250); } } const { data, error } = await withRetry(() => hatang.emails.send( { from: "Shop ", to: "customer@example.com", subject: "Hi", html: "

Hi

" }, { idempotencyKey: "welcome-user-42" }, ), ); ``` **Python** ```python import random, time from hatang import Hatang, HatangError hatang = Hatang() # reads HATANG_API_KEY def with_retry(call, attempts=5): for i in range(attempts): try: return call() except HatangError as err: # retryable: rate_limit_exceeded (429), 5xx, or the API could not be reached (status_code 0) if not err.retryable or i == attempts - 1: raise time.sleep(min(2 ** i, 30) + random.random() / 4) email = with_retry(lambda: hatang.emails.send( {"from": "Shop ", "to": "customer@example.com", "subject": "Hi", "html": "

Hi

"}, idempotency_key="welcome-user-42", )) ``` The Node SDK resolves to `{ data, error }` and does not retry on its own, so wrap calls that matter, as above. ## Stay under the limits - **Batch email.** `POST /emails/batch` sends up to 100 emails in one request, which counts as one request against the per-second limit. - **Use webhooks instead of polling.** Subscribe to `email.delivered` or `chat.message.created` rather than asking for status in a loop ([Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md)). - **Queue bulk work.** Run imports and syncs through a queue with a fixed concurrency rather than firing every request at once. - **Read the headers.** Slow down when `ratelimit-remaining` gets close to `0`. ## Next steps - [Errors](https://hatang.dev/docs/guides/getting-started/errors.md) - [Idempotency](https://hatang.dev/docs/guides/getting-started/idempotency.md) - [Limits reference](https://hatang.dev/docs/reference/limits.md) Source: https://hatang.dev/docs/guides/getting-started/rate-limits --- # Security A Ha Tang integration holds several secrets: API keys, webhook signing secrets, the chat identity secret and the Payments secret key. This page is a checklist for keeping each one safe, limiting what a leaked one can do, and verifying everything that reaches your servers. ## Know your credentials | Credential | Looks like | Where it lives | Secret? | |---|---|---|---| | API key | `ht_...` | Your server only | Yes | | Webhook signing secret | `whsec_...` | Your server only | Yes | | Chat identity secret | shown once on the website's page | Your server only | Yes | | Payments secret key | `sk_...` | Your server only | Yes | | Chat site id | `site_...` | Your web pages and apps | No, it is public | | Mobile app key | `hak_...` | Inside your app build | No, but revocable | Anything marked secret must never appear in browser JavaScript, a mobile app, a public plugin, a Git repository or a support ticket. Keep it in environment variables or a secret manager. ## API keys - **Give each key only the scopes it needs.** A server that only sends email needs `email:send`, not `*` ([Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md#scopes)). - **Use one key per service or environment**, named after where it runs. You can then revoke one without touching the others, and the **Last used** column tells you which ones are still in use. - **Restrict keys.** Lock an email key to one **Sending domain**. Only if a key must run in a browser, fill in **Browser origins**; requests from any other origin are refused ([Restricted keys](https://hatang.dev/docs/guides/getting-started/authentication.md#restricted-keys)). - **Rotate keys** when someone who knew them leaves, after a suspected leak, and on a schedule: create the new key, deploy it, then revoke the old one. A revoked key stops working within a minute. - **Audit usage.** **Email → Logs** lists every API request with the key that made it, the user agent, the status and the bodies. Unexpected callers or endpoints are a sign of a leak. Keys are shown only once when created. Ha Tang stores only a hash, so a lost key cannot be recovered; create a new one. ## Webhooks - **Verify every delivery.** Check the signature over the raw body and reject deliveries whose timestamp is more than 300 seconds old ([Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md)). Deliveries are authenticated by their signature, not by where they come from. The `user-agent: hatang-webhooks/1.0` header is informative only. - **Use HTTPS endpoints.** Ha Tang only delivers to public `https` URLs and does not follow redirects. - **Rotate the signing secret** if it may have leaked. For 24 hours after a rotation, deliveries carry signatures from both the old and the new secret, so you can deploy without dropping events. - **Deduplicate on `svix-id`** so a replayed or retried delivery cannot trigger the same action twice. - **Payments IPN calls** to your `ipn_url` are signed separately, with `Hatang-Signature` and the environment's secret key. See [Verify IPN](https://hatang.dev/docs/guides/payments/verify-ipn.md). ## Chat identity - **Verify identities.** When your site or app tells the chat who a user is, include the `hash` (HMAC-SHA256 of the user id with the site's identity secret) computed on your server. Without it, anyone could claim to be another customer and read their history ([Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md)). - **Keep the identity secret on your server**, and rotate it with **Rotate & reveal** if it may have leaked. Hashes made with the old secret stop verifying at once. - **Limit where the widget runs.** The widget only starts on your website's domain, its subdomains and the **Extra allowed origins** you list. Remove preview and localhost origins you no longer use. - **Revoke mobile app keys** you no longer ship, after releasing an app version with a new key. ## Payments - Keep the secret key of each environment on your server and check `environment` on every Payments event and IPN, so a development payment never fulfils a real order. - A `ht_test_` key can never reach production data. Production access is chosen per request with `Hatang-Environment` ([Payments environments](https://hatang.dev/docs/guides/payments/environments.md)). ## SMTP relay The SMTP relay at `smtp.hatang.dev:5870` uses STARTTLS, with the username `hatang` and an API key as the password. Give that key only the `email:send` scope. After 10 failed sign-in attempts from one IP address, the relay refuses further attempts from that address for 15 minutes. ## Your team - Give employees the lowest access level that lets them do their job, and keep **Developer** access, which can create API keys and webhooks, to the people who need it ([Team and roles](https://hatang.dev/help/account/employees-and-roles)). - Remove people who leave from **Settings → Employees**, and rotate the API keys and secrets they knew. Their keys keep working until you revoke them, because keys do not belong to a person. ## If a secret leaks 1. **API key:** revoke it in **Settings → API Keys**, create a replacement, and check **Email → Logs** for calls you did not make. 2. **Webhook secret:** rotate it and deploy the new one within 24 hours. 3. **Identity secret:** click **Rotate & reveal** and update your servers. 4. **Payments secret key:** replace it in **Payments settings → Integration keys** and update your servers. ## Next steps - [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md) - [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md) - [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md) Source: https://hatang.dev/docs/guides/getting-started/security --- # Webhooks Webhooks tell your server when something happens in Ha Tang: an email is delivered, a customer sends a chat message, a bank transfer pays an order. You register an HTTPS endpoint and pick the events you want. Ha Tang sends each event as a signed `POST` request, retries when your endpoint fails, and keeps a log of every delivery. One endpoint can receive events from every module. Signatures follow the [Svix](https://docs.svix.com/receiving/verifying-payloads/how) and [Standard Webhooks](https://www.standardwebhooks.com/) scheme, so existing libraries work. ## Before you start - You need to be an owner or admin, or a member with **Developer** access ([Team and roles](https://hatang.dev/help/account/employees-and-roles)). - Your endpoint must be reachable from the internet over HTTPS. To develop on your machine, see [Test locally](#test-locally). ## 1. Create an endpoint 1. In the dashboard, open **Settings → Webhooks** (also under **Email → Webhooks**). 2. Click **Add endpoint**, enter the URL, for example `https://shop.vn/webhooks/hatang`, and tick the events to send. 3. Copy the **signing secret**. It starts with `whsec_` and is shown only once. Or create it with the API, using a key that has any `write` scope: ```bash curl -X POST https://api.hatang.dev/webhooks \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "endpoint": "https://shop.vn/webhooks/hatang", "events": ["email.delivered", "email.bounced", "chat.message.created", "payment.succeeded"], "description": "Production backend" }' ``` ```json { "object": "webhook", "id": "wh_123...", "endpoint": "https://shop.vn/webhooks/hatang", "events": ["email.delivered", "email.bounced", "chat.message.created", "payment.succeeded"], "status": "enabled", "description": "Production backend", "created_at": "2026-10-23T09:34:12.952Z", "signing_secret": "whsec_123..." } ``` Store the secret in an environment variable such as `HATANG_WEBHOOK_SECRET`. The endpoint URL must use `https` and point to a public host. URLs with a username or password, `localhost`, `.local` or `.internal` hosts and private IP addresses are refused with `422 validation_error`. To pause deliveries, set the endpoint's `status` to `disabled` (`PATCH /webhooks/{id}`). ## 2. Choose events Every delivery has the same envelope: the event `type`, when it happened, and the object in `data`. ```json { "type": "email.delivered", "created_at": "2026-10-23T09:34:15.120Z", "data": { "email_id": "01a0b904-2978-73e8-b352-4a26fdca0ab5", "from": "Shop ", "to": ["customer@example.com"], "subject": "Your order is confirmed", "created_at": "2026-10-23T09:34:12.952Z" } } ``` | Module | Events | |---|---| | Email | `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`, `email.opened`, `email.clicked`, `email.failed`, `email.received`, `contact.created`, `contact.updated`, `contact.deleted`, `domain.created`, `domain.updated`, `domain.deleted` | | Support | `chat.conversation.created`, `.assigned`, `.resolved`, `.closed`, `.reopened`, `.handoff`, `.tagged`; `chat.message.created`; `chat.visitor.identified`; `chat.ticket.created`, `.updated`, `.resolved`, `.breached`; `chat.rating.created`; `chat.flow.webhook`; `chat.campaign.lead` | | Products | `chat.order.created`, `chat.order.updated`, `chat.order.paid` | | CRM | `chat.zns.sent`, `chat.zns.failed` | | Payments | `payment.created`, `.succeeded`, `.partially_paid`, `.overpaid`, `.expired`, `.canceled`; `transaction.received`; `bank_connection.activated`, `.revoked`, `.needs_reauth`; `invoice.*` (draft, issued, tax and correction events) | The [webhook events reference](https://hatang.dev/docs/reference/webhooks.md) describes every event and its `data`. > **Note: Payments environments** > > An endpoint receives Payments events from both the development and the production environment. Every Payments object has an `environment` field: check it before you fulfil an order. Payments can also call a per-payment `ipn_url`, which is signed differently; see [Verify IPN](https://hatang.dev/docs/guides/payments/verify-ipn.md). ## 3. Verify the signature Each delivery is a `POST` with a JSON body and these headers: ```http POST /webhooks/hatang HTTP/1.1 content-type: application/json user-agent: hatang-webhooks/1.0 svix-id: msg_01a0b90429d87cf89e37fc2f66040ec5 svix-timestamp: 1761212055 svix-signature: v1,Kk3W5m0rB1o8V0qk4bq2Jb0nU4y1sOq4uQ0b6rjB0yE= webhook-id: msg_01a0b90429d87cf89e37fc2f66040ec5 webhook-timestamp: 1761212055 webhook-signature: v1,Kk3W5m0rB1o8V0qk4bq2Jb0nU4y1sOq4uQ0b6rjB0yE= ``` | Header | Meaning | |---|---| | `svix-id` | The unique id of the event. It stays the same across retries and replays. | | `svix-timestamp` | When this attempt was signed, in Unix seconds | | `svix-signature` | One or more signatures separated by spaces, each `v1,` | | `webhook-id`, `webhook-timestamp`, `webhook-signature` | The same values under the Standard Webhooks names | To verify a delivery: 1. Take the **raw body** exactly as received, before any JSON parsing. 2. Build the signed content `..`. 3. Remove the `whsec_` prefix from the secret and base64-decode the rest: that is the HMAC key. 4. Compute HMAC-SHA256 of the signed content and base64-encode it. 5. Accept the delivery if it equals any `v1` signature in `svix-signature`, using a constant-time comparison. 6. Reject it if `svix-timestamp` is more than **300 seconds** away from your clock. This blocks replays of old captured requests. **Node.js** `@hatang/sdk` does all six steps. `constructWebhookEvent` returns the parsed event or throws `HatangSignatureError`; `verifyWebhook` returns `true` or `false`. Both accept the `svix-*` or `webhook-*` headers and a default tolerance of 300 seconds. ```js import express from "express"; import { constructWebhookEvent, HatangSignatureError } from "@hatang/sdk"; const app = express(); // express.raw keeps the body as a Buffer. Do not use express.json() on this route. app.post("/webhooks/hatang", express.raw({ type: "application/json" }), async (req, res) => { let event; try { event = await constructWebhookEvent(req.body, req.headers, process.env.HATANG_WEBHOOK_SECRET); } catch (err) { if (err instanceof HatangSignatureError) return res.status(400).send("Invalid signature"); throw err; } await queue.add("hatang-event", { id: req.get("svix-id"), event }); // handle it in the background res.sendStatus(200); }); ``` In a Next.js route handler or any runtime with the Fetch API: ```js import { verifyWebhook } from "@hatang/sdk"; export async function POST(request) { const raw = await request.text(); if (!(await verifyWebhook(raw, request.headers, process.env.HATANG_WEBHOOK_SECRET))) { return new Response("Invalid signature", { status: 400 }); } const event = JSON.parse(raw); // ... return new Response(null, { status: 200 }); } ``` The [`svix`](https://www.npmjs.com/package/svix) package works too: `new Webhook(secret).verify(rawBody, headers)`. **Python** ```python 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("/webhooks/hatang") def hatang_webhook(): try: # raw bytes, before parsing; checks the signature and the 5-minute timestamp window event = construct_webhook_event(request.get_data(), request.headers, SECRET) except HatangSignatureError: abort(400) enqueue(request.headers["svix-id"], event) # handle it in the background return "", 200 ``` **PHP** ```php $tolerance) { return false; } $key = base64_decode(str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret); $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true)); foreach (explode(' ', $signatures) as $part) { [$version, $sig] = array_pad(explode(',', $part, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $sig)) { return true; } } return false; } $raw = file_get_contents('php://input'); if (!hatang_verify_webhook($raw, getallheaders(), getenv('HATANG_WEBHOOK_SECRET'))) { http_response_code(400); exit; } $event = json_decode($raw, true); // Queue $event for processing, then answer quickly. http_response_code(200); ``` **Go** ```go package webhooks import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "io" "net/http" "os" "strconv" "strings" "time" ) func header(h http.Header, name string) string { if v := h.Get("svix-" + name); v != "" { return v } return h.Get("webhook-" + name) } func VerifyWebhook(body []byte, h http.Header, secret string) bool { id, ts, sigs := header(h, "id"), header(h, "timestamp"), header(h, "signature") if id == "" || ts == "" || sigs == "" { return false } sec, err := strconv.ParseInt(ts, 10, 64) if err != nil { return false } if age := time.Since(time.Unix(sec, 0)); age > 300*time.Second || age < -300*time.Second { return false } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + ".")) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) for _, part := range strings.Split(sigs, " ") { version, sig, ok := strings.Cut(part, ",") if ok && version == "v1" && hmac.Equal([]byte(sig), []byte(expected)) { return true } } return false } func Handler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil || !VerifyWebhook(body, r.Header, os.Getenv("HATANG_WEBHOOK_SECRET")) { http.Error(w, "invalid signature", http.StatusBadRequest) return } // json.Unmarshal(body, &event), queue it, then answer. w.WriteHeader(http.StatusOK) } ``` > **Caution: Verify the raw body** > > Always verify the bytes you received. Parsing the JSON and serializing it again changes spacing or key order and breaks the signature. In frameworks that parse JSON automatically, turn that off for the webhook route. ## 4. Respond quickly Answer with any `2xx` status within **15 seconds**. Anything else counts as a failure: another status, a timeout, a connection error, or a redirect (redirects are not followed). The first 4 KB of your response body is saved with the delivery, which helps when debugging. Do the real work in a background job: verify, store or enqueue the event, answer `200`, then process. ## Retries When a delivery fails, Ha Tang tries again on this schedule, measured from the previous attempt: | Attempt | Delay | |---|---| | 1 | Immediately | | 2 | 5 seconds | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 | 5 hours | | 7 | 10 hours | | 8 | 10 hours | After the eighth failed attempt, about 27.5 hours after the event, the delivery is marked as exhausted and not retried again. You can still [replay](#replay-a-delivery) it. Pending deliveries of an endpoint that is disabled or deleted are dropped. ## Replay a delivery {#replay-a-delivery} The endpoint's page in the dashboard lists recent **Deliveries** with the event, the attempt number, your endpoint's response and the **Next retry** time. Click **Replay** to send a delivery again, for example after you fix a bug in your handler. With the API: ```bash # The last 50 deliveries of an endpoint curl https://api.hatang.dev/webhooks/wh_123.../deliveries \ -H "Authorization: Bearer $HATANG_API_KEY" # Send one again curl -X POST https://api.hatang.dev/webhooks/deliveries/whd_123.../replay \ -H "Authorization: Bearer $HATANG_API_KEY" ``` A replay goes through the same retry schedule and carries the **same `svix-id`** as the original, so a handler that deduplicates on it will not process the event twice. ## Rotate the signing secret Rotate the secret if it may have leaked, or on a schedule. Click **Rotate secret** on the endpoint's page, or call `POST /webhooks/{id}/rotate`, which returns `{ "id": "wh_123...", "secret": "whsec_..." }`. For **24 hours** after a rotation, every delivery is signed with both the new and the old secret: `svix-signature` contains two `v1` signatures. Your current code keeps working while you deploy the new secret, and the verification code above accepts a delivery if any signature matches. ## Test locally {#test-locally} Ha Tang only delivers to public HTTPS addresses, so expose your local server through a tunnel such as [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) or [ngrok](https://ngrok.com/), and register the tunnel's URL as a separate endpoint: ```bash cloudflared tunnel --url http://localhost:3000 # or ngrok http 3000 ``` Then trigger a real event, such as sending an email to yourself, or replay an earlier delivery from the dashboard. Use the new endpoint's own signing secret on your machine, and delete the endpoint when you are done. ## Make your handler idempotent {#make-your-handler-idempotent} Treat every delivery as "at least once": - **Deduplicate on `svix-id`.** The same event can arrive more than once, after a timeout on your side, a retry or a replay. Store the ids you have processed and skip repeats. - **Do not rely on order.** Retries can deliver an older event after a newer one. Compare `created_at`, or fetch the current state of the object from the API before acting on it. - **Answer `2xx` for events you ignore**, so they are not retried for a day. - **Check the environment** of Payments events, so a development payment never fulfils a real order. ## Next steps - [Webhook events reference](https://hatang.dev/docs/reference/webhooks.md) - [Idempotency](https://hatang.dev/docs/guides/getting-started/idempotency.md) - [Security](https://hatang.dev/docs/guides/getting-started/security.md) - [Payments webhooks](https://hatang.dev/docs/guides/payments/webhooks.md) Source: https://hatang.dev/docs/guides/getting-started/webhooks --- # Ha Tang Chat plugin for WordPress The Ha Tang Chat plugin adds the Ha Tang chat widget to every page of your WordPress site, so your team answers website visitors from the same inbox as Zalo, Messenger, Instagram, TikTok, WhatsApp, Telegram and email. It loads the regular [web widget](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md) through `wp_enqueue_script` and adds settings for position, language and logged-in users. > **Info: Version** > > Plugin **0.2.1** · WordPress 6.0+ · PHP 7.4+ · GPLv2 or later · [wordpress.org](https://wordpress.org/plugins/hatang-chat/) · [Changelog](#changelog) ## Before you start - A Ha Tang account with a website channel. You need its **Site ID** (`site_...`): in the dashboard, open **Support → Channels**, pick your website and copy the id from the install snippet. - A WordPress user who can manage plugins and settings (an administrator). ## Install the plugin {#install} 1. In WordPress, go to **Plugins → Add New**, search for "Ha Tang Chat", then install and activate it. You can also upload the plugin zip with **Plugins → Add New → Upload Plugin**. 2. Go to **Settings → Ha Tang Chat**, paste the **Site ID** and click **Save Changes**. 3. Open any page of your site. The chat bubble appears, and the website in the Ha Tang dashboard shows that the widget is installed. The widget only loads on the public side of the site, never in wp-admin. If the Site ID is empty, the plugin loads nothing. ## Settings {#settings} All settings are on **Settings → Ha Tang Chat** and are stored in one option, `hatang_chat`. | Setting | Values | Default | |---|---|---| | Site ID | The website's id from the Ha Tang dashboard | empty (widget off) | | Widget host | Where `widget.js` is loaded from | `https://chat.hatang.dev` | | Position | Bottom right, Bottom left | Bottom right | | Language | **Follow the WordPress language (per page)**, **Visitor's browser language**, **Tiếng Việt**, **English** | Follow the WordPress language (sites installed before 0.2.0 keep the browser language) | | Logged-in users | "Send the user's name and email to the inbox" | On | - **Follow the WordPress language** reads the language of the page being served (`determine_locale()`), so it works page by page with Polylang and WPML. A locale starting with `vi` gives Vietnamese; any other gives English. - **Visitor's browser language** lets the widget decide: the visitor's browser, or the default language set for the site in the Ha Tang dashboard. - Everything else (colors, office hours, greeting, offline form) comes from the website's settings in the Ha Tang dashboard. For options the plugin does not expose, such as `offset` or `hideLauncher`, add your own `window.HATANG_CONFIG` before the widget script, or use the [JavaScript API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md) from your theme. ## Logged-in users {#identify} With **Logged-in users** on, the plugin calls `identify` for a logged-in visitor with their WordPress display name and email. The inbox shows them as information the visitor gave, not as a verified identity. To have the inbox mark the user as **verified** and merge the conversation with that person's earlier sessions (including your mobile app), return a signature from the `hatang_chat_identity_hash` filter. The signature is the HMAC-SHA256 of the WordPress user id with the site's identity secret. Get the secret in the Ha Tang dashboard: **Support → Channels** → your website → **Identity secret** → **Rotate & reveal**. Keep it in `wp-config.php`, never in a theme file others can read. ```php title="wp-config.php" define( 'HATANG_IDENTITY_SECRET', 'your-identity-secret' ); ``` ```php title="functions.php" add_filter( 'hatang_chat_identity_hash', function ( $hash, $user ) { return hash_hmac( 'sha256', (string) $user->ID, HATANG_IDENTITY_SECRET ); }, 10, 2 ); ``` When the filter returns a value, the plugin adds `userId` (the WordPress user id as a string) and `hash` to the `identify` call. What a signature means, how sessions merge and the privacy notes are in [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md). ## Filters for developers {#filters} | Filter | Arguments | Return | |---|---|---| | `hatang_chat_locale` | `$locale` (`"vi"`, `"en"` or `null`), `$setting` (`"wp"`, `""`, `"vi"` or `"en"`) | `"vi"`, `"en"`, or `null` to let the widget decide | | `hatang_chat_identity_hash` | `$hash` (`null`), `$user` (`WP_User`) | The HMAC-SHA256 hex string, or `null` for an unverified identity | Force English on one page, whatever the setting: ```php title="functions.php" add_filter( 'hatang_chat_locale', function ( $locale, $setting ) { return is_page( 'en' ) ? 'en' : $locale; }, 10, 2 ); ``` ## What the plugin sends to Ha Tang {#data} The widget script loads from `chat.hatang.dev` on the public pages of your site. When a visitor opens a page, it sends the page URL and title, the visitor's browser language and, if you turned it on, the name and email of a logged-in user. See the [Terms](https://hatang.dev/legal/terms) and the [Privacy policy](https://hatang.dev/legal/privacy). ## Admin translation {#translation} The settings page is translated into Vietnamese (`languages/hatang-chat-vi.po`). WordPress picks the translation from the admin language. ## Uninstall {#uninstall} Deactivate and delete the plugin from **Plugins**. The plugin has no uninstall routine, so its settings stay in the `hatang_chat` option; remove it with `wp option delete hatang_chat` if you want a clean database. Conversations stay in your Ha Tang account until you delete them there. ## Changelog {#changelog} - **0.2.3**: the author link points to hatang.dev; the readme links the setup guide and this developer guide and answers which conversations share the inbox. - **0.2.2**: the plugin is called Ha Tang Chat (Hạ Tầng in Vietnamese); its homepage is the help article at hatang.dev/help/integrations/wordpress; the settings page and readme name **Customer Support → Channels**. - **0.2.1**: the settings page and readme point to **Support → Channels**, where the Ha Tang dashboard now lists your websites; the readme lists every channel that shares the inbox. - **0.2.0**: the language can follow the WordPress language of each page (works with Polylang and WPML); new `hatang_chat_locale` filter; settings page translated into Vietnamese. - **0.1.1**: the widget loads through `wp_enqueue_script`. - **0.1.0**: first release: widget snippet, position and language settings, logged-in user identification. ## Next steps - [Web widget and JS API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md): commands, events and `HATANG_CONFIG`. - [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md): signed identities and session merging. - [Install the widget](https://hatang.dev/docs/guides/support/web-widget/install.md): other platforms, or WordPress without the plugin. Source: https://hatang.dev/docs/guides/integrations/wordpress --- # Bank connections A bank connection is the link between Ha Tang and the bank account your customers pay into. Ha Tang only reads the account's incoming transfers; it never moves money. This page shows how to link each bank, configure matching, unlink an account, and import a statement when the bank missed a transfer. ## Before you start - An API key with `payments:write`, or access to **Payments settings → Bank accounts** in the dashboard. - For a real bank: the account number, the holder's name exactly as the bank has it, and (for MB Bank) the national ID or tax code the account was opened with. - Pick the environment. Real banks exist only in `production`; the development bank only in `development`. Asking for the wrong one answers `403 environment_mismatch`. ## Endpoints | Method | Endpoint | Does | |---|---|---| | `GET` | `/payments/bank-connections` | List the linked accounts (unlinked ones are left out) | | `POST` | `/payments/bank-connections` | Start linking | | `GET` | `/payments/bank-connections/{id}` | Retrieve one | | `PATCH` | `/payments/bank-connections/{id}` | Change the label or the matching settings | | `POST` | `/payments/bank-connections/{id}/confirm` | Confirm linking with the bank's code | | `POST` | `/payments/bank-connections/{id}/unlink` | Start unlinking | | `POST` | `/payments/bank-connections/{id}/unlink/confirm` | Confirm unlinking with the bank's code | | `POST` | `/payments/statements` | Import a statement (CSV) | | `GET` | `/payments/statements/{id}` | Statement import progress | All of them need `payments:read` (reads) or `payments:write` (changes). ## How each bank links | Bank | `provider` | `link_mode` | What you do | |---|---|---|---| | MB Bank | `mbbank` | `otp` | Send the account details; MB texts a code to the phone registered for the account; confirm with it | | BIDV | `bidv` | `bank_registered` | Register BIDV's balance notification service at BIDV for the account, then send the account number. The connection activates when BIDV sends its first message for it | | ACB | `acb` | `redirect` | Send the account number, then open `challenge.authorize_url` so the holder consents on ACB's page. They come back to the dashboard and the account is active | | Pay2S | `pay2s` | `bank_registered` | Link the account on Pay2S, create a Hook there pointing at `webhook_url`, then send the account number, `bank_code` and the Hook's token. The connection activates with the first push | | Development bank | `simulator` | `otp` | Confirm with the code `000000` | ## Link an MB Bank account {#link-an-account} ```mermaid sequenceDiagram participant S as You participant H as Ha Tang participant B as Bank S->>H: POST /payments/bank-connections H->>B: Check the account against the holder's ID B-->>S: Code by SMS to the account holder H-->>S: status pending_otp + challenge S->>H: POST /payments/bank-connections/{id}/confirm H->>B: Confirm H-->>S: status active + webhook bank_connection.activated ``` ### 1. Start linking **cURL** ```bash curl https://api.hatang.dev/payments/bank-connections \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "provider": "mbbank", "account_number": "910452658", "account_name": "NGUYEN VAN A", "account_type": "personal", "holder_id": "001099012345", "label": "Shop account" }' ``` **Node.js** ```ts const { data: conn, error } = await hatang.payments.bankConnections.create({ provider: "mbbank", account_number: "910452658", account_name: "NGUYEN VAN A", account_type: "personal", holder_id: "001099012345", label: "Shop account", }); if (error) throw new Error(`${error.name}: ${error.message}`); ``` **Python** ```python conn = requests.post("https://api.hatang.dev/payments/bank-connections", headers=HEADERS, json={ "provider": "mbbank", "account_number": "910452658", "account_name": "NGUYEN VAN A", "account_type": "personal", "holder_id": "001099012345", "label": "Shop account", }, timeout=30).json() ``` **PHP** ```php $conn = json_decode($client->post('/payments/bank-connections', ['json' => [ 'provider' => 'mbbank', 'account_number' => '910452658', 'account_name' => 'NGUYEN VAN A', 'account_type' => 'personal', 'holder_id' => '001099012345', 'label' => 'Shop account', ]])->getBody(), true); ``` | Field | Description | |---|---| | `provider` | **Required.** `mbbank`, `bidv`, `acb` or `pay2s` in production; `simulator` in development | | `account_number` | Required for real banks. Digits (Pay2S also allows capital letters), 4 to 30 characters. The development bank makes one up when you leave it out | | `account_name` | The holder's name exactly as the bank has it. Required for MB Bank | | `account_type` | `personal` (default) or `business` | | `holder_id` | National ID (personal) or tax code (business) the account was opened with. Required for MB Bank; stored encrypted | | `bank_code` | For a provider that serves several banks (Pay2S, the development bank): which bank the account is at, e.g. `BIDV`, `MB`, `VCB`. It decides the bank the VietQR names. Refused for the other providers | | `webhook_token` | Pay2S only, and required there: the token of the Hook you created on Pay2S | | `label` | Your name for the account, up to 100 characters | | `config` | Matching settings; see [Matching settings](#config) | The answer is `201` with the connection in `pending_otp` and a `challenge`: ```json { "object": "bank_connection", "id": "bcn_01a0b90428fd7c499145a12c133abe41", "environment": "production", "provider": "mbbank", "bank": { "bin": "970422", "name": "MB Bank" }, "account_number": "910452658", "account_name": "NGUYEN VAN A", "account_type": "personal", "status": "pending_otp", "label": "Shop account", "config": { "code_prefix": "HT", "payment_code_pattern": null, "transaction_events": { "direction": "in", "require_code": false, "min_amount": 0 } }, "capabilities": { "pushNotifications": true, "history": false, "accountInfo": false, "virtualAccounts": false, "refunds": false }, "link_mode": "otp", "webhook_url": null, "consent": null, "last_transaction_at": null, "activated_at": null, "revoked_at": null, "created_at": "2026-09-19T09:29:51.402Z", "challenge": { "id": "lch_01a0b90428e17d2c8b5f0a4e3c2d1b0a", "expires_at": "2026-09-19T09:34:51.402Z", "delivery": "sms_otp" } } ``` `challenge.delivery` tells you what happens next: `sms_otp` (a code is on its way), `redirect` (open `challenge.authorize_url`) or `bank_registration` (wait for the bank's first message). ### 2. Confirm with the code **cURL** ```bash curl https://api.hatang.dev/payments/bank-connections/bcn_123/confirm \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "otp": "123456" }' ``` **Node.js** ```ts const { data, error } = await hatang.payments.bankConnections.confirm(conn.id, "123456"); ``` **Python** ```python requests.post(f"https://api.hatang.dev/payments/bank-connections/{conn['id']}/confirm", headers=HEADERS, json={"otp": "123456"}, timeout=30).raise_for_status() ``` **PHP** ```php $client->post("/payments/bank-connections/{$conn['id']}/confirm", ['json' => ['otp' => '123456']]); ``` `otp` is 4 to 8 digits. On success the connection is `active` and the `bank_connection.activated` webhook fires. A wrong code counts as an attempt; after 5 wrong attempts the code is dead and you start again. ## Link a BIDV, ACB or Pay2S account - **BIDV.** Register the balance notification service for the account at BIDV, then `POST` with `provider: "bidv"` and `account_number`. The connection stays `pending_otp` (shown as "Waiting for the bank" in the dashboard) until BIDV sends its first message for the account, which activates it. Calling `confirm` answers `409 invalid_state`. - **ACB.** `POST` with `provider: "acb"` and `account_number`, then send the account holder to `challenge.authorize_url` within a few minutes. After they consent on ACB's page, ACB sends them back to **Payments settings → Bank accounts** and the account is `active`. The consent covers reading the account's history, lasts up to 180 days and appears in the connection's `consent` field. ACB does not push transfers; Ha Tang reads the history every 10 minutes. - **Pay2S.** Link the account on Pay2S first. Read `webhook_url` from any Pay2S connection (or from the dashboard form), create a Hook on Pay2S pointing at it, and copy the Hook's token. Then `POST` with `provider: "pay2s"`, `bank_code`, `account_number` (as Pay2S writes it) and `webhook_token`. The connection activates when Pay2S pushes its first transaction for the account. Pay2S reads accounts at `VCB`, `ICB`, `TCB`, `BIDV`, `ACB`, `MB` and `TPB`. ## Statuses | `status` | Meaning | |---|---| | `pending_otp` | Waiting: for the code, for the holder's consent, or for the bank's first message | | `active` | Receiving transfers | | `needs_reauth` | The bank wants the account linked again | | `revoked` | Unlinked | | `error` | The bank reported a problem with the link | ## Matching settings (`config`) {#config} Change them with `PATCH`, or in the dashboard under **Payments settings → Bank accounts → Matching and notifications**. Only the fields you send change. **cURL** ```bash curl -X PATCH https://api.hatang.dev/payments/bank-connections/bcn_123 \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "label": "Main shop", "config": { "code_prefix": "SHOP", "payment_code_pattern": { "prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits" }, "transaction_events": { "direction": "in", "require_code": true, "min_amount": 10000 } } }' ``` **Node.js** ```ts await hatang.payments.bankConnections.update("bcn_123", { label: "Main shop", config: { code_prefix: "SHOP", payment_code_pattern: { prefix: "DH", min_length: 4, max_length: 8, charset: "digits" }, transaction_events: { direction: "in", require_code: true, min_amount: 10_000 }, }, }); ``` **Python** ```python requests.patch("https://api.hatang.dev/payments/bank-connections/bcn_123", headers=HEADERS, json={ "label": "Main shop", "config": { "code_prefix": "SHOP", "payment_code_pattern": {"prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits"}, "transaction_events": {"direction": "in", "require_code": True, "min_amount": 10000}, }, }, timeout=10).raise_for_status() ``` **PHP** ```php $client->patch('/payments/bank-connections/bcn_123', ['json' => [ 'label' => 'Main shop', 'config' => [ 'code_prefix' => 'SHOP', 'payment_code_pattern' => ['prefix' => 'DH', 'min_length' => 4, 'max_length' => 8, 'charset' => 'digits'], 'transaction_events' => ['direction' => 'in', 'require_code' => true, 'min_amount' => 10000], ], ]]); ``` | Field | Default | Description | |---|---|---| | `code_prefix` | `HT` | Prefix of generated payment codes, 2 to 5 capital letters (orders flow) | | `payment_code_pattern` | `null` | Your own order code format, looked for in every transfer (transfers-only flow). `null` turns it off | | `payment_code_pattern.prefix` | – | 1 to 10 capital letters or digits | | `payment_code_pattern.min_length` / `max_length` | `3` / `10` | Length of the part after the prefix, 1 to 30 | | `payment_code_pattern.charset` | `digits` | `digits` or `alnum` (letters and digits) | | `transaction_events.direction` | `in` | `in`, or `all` to report money going out too | | `transaction_events.require_code` | `false` | Only report transfers where a code was found or a payment matched | | `transaction_events.min_amount` | `0` | Do not report transfers below this amount (VND) | `transaction_events` only filters `transaction.received`. Payments are always matched and reported. ## Unlink an account **cURL** ```bash curl -X POST https://api.hatang.dev/payments/bank-connections/bcn_123/unlink \ -H "Authorization: Bearer $HATANG_API_KEY" # Only when the answer has a challenge: curl https://api.hatang.dev/payments/bank-connections/bcn_123/unlink/confirm \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "otp": "123456" }' ``` **Node.js** ```ts const { data } = await hatang.payments.bankConnections.unlink("bcn_123"); if (data?.status !== "revoked") await hatang.payments.bankConnections.confirmUnlink("bcn_123", "123456"); ``` **Python** ```python conn = requests.post("https://api.hatang.dev/payments/bank-connections/bcn_123/unlink", headers=HEADERS, timeout=30).json() if conn.get("challenge"): requests.post("https://api.hatang.dev/payments/bank-connections/bcn_123/unlink/confirm", headers=HEADERS, json={"otp": "123456"}, timeout=30).raise_for_status() ``` **PHP** ```php $conn = json_decode($client->post('/payments/bank-connections/bcn_123/unlink')->getBody(), true); if (!empty($conn['challenge'])) { $client->post('/payments/bank-connections/bcn_123/unlink/confirm', ['json' => ['otp' => '123456']]); } ``` MB Bank confirms unlinking with a code, like linking. The other banks, and an account still waiting to be linked, are unlinked at once: the answer is already `revoked` and `challenge` is `null`. Unlinking an ACB account also withdraws the consent at ACB. To stop BIDV's or Pay2S's pushes, turn the service off on their side too. Unlinking fires `bank_connection.revoked`. ## Import a statement {#statement-import} Statement import recovers transfers the bank never pushed, for any bank. Download the statement from your bank, convert it to this CSV, and upload it in the dashboard (**Payments settings → Bank accounts → Import a bank statement**) or through the API. ```csv reference,booked_at,direction,amount,description,counterparty_account,counterparty_name FT1789810452930810,2026-09-19T09:34:12Z,in,99000,DH20250919 chuyen tien,0987654321,NGUYEN VAN KHACH ``` **cURL** ```bash curl https://api.hatang.dev/payments/statements \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg csv "$(cat statement.csv)" \ '{ connection_id: "bcn_123", filename: "statement-september.csv", content: $csv }')" ``` **Node.js** ```ts import { readFile } from "node:fs/promises"; const { data: job } = await hatang.payments.statements.import({ connection_id: "bcn_123", filename: "statement-september.csv", content: await readFile("statement.csv", "utf8"), }); const { data: progress } = await hatang.payments.statements.get(job.id); ``` **Python** ```python with open("statement.csv", encoding="utf-8") as f: job = requests.post("https://api.hatang.dev/payments/statements", headers=HEADERS, json={ "connection_id": "bcn_123", "filename": "statement-september.csv", "content": f.read(), }, timeout=30).json() ``` **PHP** ```php $job = json_decode($client->post('/payments/statements', ['json' => [ 'connection_id' => 'bcn_123', 'filename' => 'statement-september.csv', 'content' => file_get_contents('statement.csv'), ]])->getBody(), true); ``` The call answers `202` and the rows are processed in the background. Follow them with `GET /payments/statements/{id}`: ```json { "object": "statement_import", "id": "stm_01a0b9042a1c7f0e9d8c7b6a5f4e3d2c", "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41", "filename": "statement-september.csv", "status": "done", "rows": 1, "processed_rows": 1, "error": null, "created_at": "2026-09-20T02:10:00.000Z", "completed_at": "2026-09-20T02:10:01.250Z" } ``` | Rule | Detail | |---|---| | Columns | `reference`, `booked_at`, `direction`, `amount`, `description` are required; `counterparty_account` and `counterparty_name` are optional. The header row names them, in any order | | `direction` | `in`, `out`, `c`, `d`, `credit` or `debit` | | `amount` | A positive whole number of VND; thousands separators (`99.000`, `99,000`) are accepted | | `booked_at` | An ISO 8601 date and time. A value that cannot be read becomes the import time | | Size | `content` up to 5,000,000 characters | | Duplicates | A transfer Ha Tang already has (same reference and amount on the account) is skipped | | `status` | `queued`, `processing`, `done` or `failed` (with `error`) | Imported transfers go through the same matching as pushed ones: a transfer that carries a payment code settles that payment and fires the usual events. A missing column or a bad row is refused up front with `422 validation_error`, for example `Statement is missing the "amount" column` or `Statement row 3 is invalid`. Nothing is stored in that case. ## Errors | Error | When | |---|---| | `403 environment_mismatch` | A real bank in development, or the development bank in production | | `422 missing_required_field` | MB Bank without `account_number`, `account_name` and `holder_id`; BIDV or Pay2S without `account_number`; Pay2S without `webhook_token` | | `422 validation_error` | Unknown `provider`, a malformed account number, `bank_code` for a bank that does not take one, or a `bank_code` Pay2S does not read | | `422 bank_link_failed` | The bank refused: unknown account, name or ID that does not match, account linked to another business, or the holder declined consent | | `422 invalid_otp` | Wrong code | | `410 otp_expired` | The code expired; start linking again | | `409 invalid_state` | The account is already linked, already being linked, already unlinked, or does not confirm with a code | | `503 provider_unavailable` | The bank did not answer; try again later | ## Next steps - [Receive transfers without orders](https://hatang.dev/docs/guides/payments/transactions.md) with `payment_code_pattern`. - [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md) into the linked account. - [Webhooks](https://hatang.dev/docs/guides/payments/webhooks.md): `bank_connection.*` events. Source: https://hatang.dev/docs/guides/payments/bank-connections --- # How the payer pays Once you have [created a payment](https://hatang.dev/docs/guides/payments/create-payment.md), the payer can scan its VietQR, use the hosted checkout page, or transfer by hand. This page shows each option, the public endpoints for building your own payment page, and what happens when the payer makes a mistake. ## Scan the VietQR Any banking app in Vietnam scans it (NAPAS 247: instant, 24/7). The QR fills in your bank, the account number, the amount and the payment code; the payer only confirms.
A sample VietQR with the hatang.dev mark
Sample VietQR: 910452658 · 150,000 ₫ · HTBF7RDGVQ
- `qr.image_url`: an SVG image you can put straight in an `` tag. It has the Ha Tang mark in the middle and is drawn at error correction level H, so the mark does not hurt scanning. Add `?logo=0` for a plain code, for example to print it under your own branding. - `qr.payload`: the EMVCo string. Render it with your own QR library for a custom design. ## Use the hosted checkout page
The Ha Tang checkout page
Redirect the payer to `checkout_url`. The page shows the QR, the bank details with copy buttons, and a countdown to `expires_at`. It updates live when the money arrives (and shows the invoice number once there is one), then sends the payer to your `return_url`. It needs no login: the payment id is the only credential. In development it also has a button that simulates the transfer. > **Note** > > Coming back to `return_url` only means the payer left the checkout page. Confirm the payment on your server before you fulfil the order (see [Confirm before you fulfil](https://hatang.dev/docs/guides/payments/sdk-javascript.md#confirm-before-you-fulfil)). ## Transfer by hand Show `bank.name`, `bank.account_number`, `bank.account_name`, the amount and the `code`, and ask the payer to put the code in the transfer description. This works from any bank, including internet banking on a computer. Show the code prominently: without it, the transfer cannot be matched to the payment. ## Build your own payment page These endpoints need no API key, so you can call them from the browser: | Endpoint | Returns | |---|---| | `GET /pay/{id}` | What the checkout page shows, and only what the payer needs | | `GET /pay/{id}/qr.svg` | The VietQR image (`?logo=0` for a plain one) | | `GET /pay/{id}/events` | Server-sent events, one each time the status changes | `GET /pay/{id}` returns: ```json { "object": "checkout", "id": "pay_01a0b904297873e8b3524a26fdca0ab5", "environment": "production", "status": "pending", "amount": 150000, "paid_amount": 0, "currency": "VND", "code": "HTBF7RDGVQ", "description": "Order #1001", "merchant": { "name": "Example Shop" }, "bank": { "bin": "970422", "name": "MB Bank", "account_number": "910452658", "account_name": "NGUYEN VAN A", "simulated": false }, "qr": { "payload": "00020101021238530010A0000007270123000697042201099104526580208QRIBFTTA530370454061500005802VN62140810HTBF7RDGVQ63044DEC", "image_url": "https://api.hatang.dev/pay/pay_01a0b904297873e8b3524a26fdca0ab5/qr.svg" }, "return_url": "https://shop.example.com/orders/1001", "expires_at": "2026-09-19T10:34:12.856Z", "paid_at": null, "invoice": null } ``` `bank.simulated` is `true` for a made-up development account. `invoice`, when the order asked for one, holds only `requested`, `status`, `number`, `series`, `lookup_code` and `pdf_url`. Listen for status changes: ```js const es = new EventSource(`https://api.hatang.dev/pay/${paymentId}/events`); es.addEventListener("status", (e) => { const { status, paid_amount } = JSON.parse(e.data); if (status === "paid" || status === "overpaid") showThankYou(); // display only if (status === "partially_paid") showRemaining(paid_amount); }); ``` The stream sends the current status first, then one `status` event per change, and a keep-alive comment every 15 seconds. It closes after 15 minutes; open it again if the payer is still on the page. If the stream is refused (each payment and each server cap live connections), fall back to polling `GET /pay/{id}` every few seconds. | Limit | Value | |---|---| | Requests to `/pay/*` per client address | 600 per minute (`429 rate_limit_exceeded` beyond, with `Retry-After`) | | Payments module switched off | `403 product_disabled` | > **Danger: Never fulfil from the browser** > > Anyone can open a page that says "paid". Confirm on your server with the IPN, a webhook or `GET /payments/{id}`. ## When the payer makes a mistake | Situation | Result | |---|---| | Wrong amount | The payment becomes `partially_paid` or `overpaid`. A second transfer with the same code adds up | | Forgot the code | The transfer arrives as `transaction.received` with `payment_id: null`. Check it, then [mark the payment paid](https://hatang.dev/docs/guides/payments/create-payment.md) | | Paid after `expires_at` | The money is recorded and the payment becomes `paid` | | Paid twice | The payment becomes `overpaid`; refund the difference from your bank | | Paid into the wrong account | Ha Tang only sees linked accounts; the payment stays unpaid | ## Next steps - [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md) on your server. - [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md): statuses and edge cases. - [E-invoices](https://hatang.dev/docs/guides/payments/einvoices.md): what the checkout page shows about the invoice. Source: https://hatang.dev/docs/guides/payments/buyer-flow --- # Create a payment A payment is one order's request for money. It carries a transfer code, a VietQR and a hosted checkout page, and Ha Tang moves it to `paid` when a transfer with that code arrives. This page covers creating payments, their statuses, the payment code, and the calls to cancel or settle a payment by hand. ## Before you start - An active [bank connection](https://hatang.dev/docs/guides/payments/bank-connections.md) in the environment you use. - An API key with `payments:write`. - An HTTPS endpoint for the [IPN](https://hatang.dev/docs/guides/payments/payment-results.md), if you want calls for this payment. ## 1. Create the payment Create it when the customer checks out. Send an `Idempotency-Key` (your order id works well) so that a retried request returns the same payment instead of a second one. **cURL** ```bash curl https://api.hatang.dev/payments \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Idempotency-Key: order-1001" \ -H "Content-Type: application/json" \ -d '{ "amount": 150000, "description": "Order #1001", "expires_in": 3600, "return_url": "https://shop.example.com/orders/1001", "ipn_url": "https://shop.example.com/hatang/ipn", "customer": { "name": "Tran Thi B", "email": "b@example.com" }, "metadata": { "order_id": "1001" } }' ``` **Node.js** ```ts const { data: payment, error } = await hatang.payments.create( { amount: 150_000, description: "Order #1001", expires_in: 3600, return_url: "https://shop.example.com/orders/1001", ipn_url: "https://shop.example.com/hatang/ipn", customer: { name: "Tran Thi B", email: "b@example.com" }, metadata: { order_id: "1001" }, }, { idempotencyKey: "order-1001" }, ); if (error) throw new Error(`${error.name}: ${error.message}`); ``` **Python** ```python payment = hatang.payments.create( { "amount": 150000, "description": "Order #1001", "expires_in": 3600, "return_url": "https://shop.example.com/orders/1001", "ipn_url": "https://shop.example.com/hatang/ipn", "customer": {"name": "Tran Thi B", "email": "b@example.com"}, "metadata": {"order_id": "1001"}, }, idempotency_key="order-1001", ) # an error raises hatang.HatangError with .name and .message ``` **PHP** ```php post('/payments', [ 'headers' => ['Idempotency-Key' => 'order-1001'], 'json' => [ 'amount' => 150000, 'description' => 'Order #1001', 'expires_in' => 3600, 'return_url' => 'https://shop.example.com/orders/1001', 'ipn_url' => 'https://shop.example.com/hatang/ipn', 'customer' => ['name' => 'Tran Thi B', 'email' => 'b@example.com'], 'metadata' => ['order_id' => '1001'], ], ]); $payment = json_decode($res->getBody(), true); } catch (ClientException $e) { $err = json_decode($e->getResponse()->getBody(), true); throw new RuntimeException("{$err['name']}: {$err['message']}"); } ``` | Field | Meaning | |---|---| | `amount` | **Required.** VND, VAT included: what the payer transfers. A whole number from 1 to 100,000,000,000 | | `description` | Shown to the payer on the checkout page, up to 200 characters. It is not the transfer description | | `code` | Your own transfer code instead of a generated one; see [Your own code](#your-own-code) | | `connection_id` | Which bank account receives the money. Required only when several accounts are active | | `expires_in` | Seconds until the payment expires: 1 to 2,592,000 (30 days). Default 86,400 (24 hours) | | `return_url` | Where the checkout page sends the payer after paying | | `ipn_url` | Your endpoint for this payment's IPN calls (see [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md)). Public `https` only | | `customer` | `name`, `email`, `phone`. Yours; returned as given | | `metadata` | Your own key-value data. Keys up to 40 characters; values are strings (up to 500 characters), numbers, booleans or `null`. Returned as given | | `invoice` | Issue an e-invoice once paid: the [invoice request](https://hatang.dev/docs/guides/payments/einvoices.md#invoice-request) (buyer, line items, VAT). See [E-invoices](https://hatang.dev/docs/guides/payments/einvoices.md) | ### Idempotency The same `Idempotency-Key` with the same body returns the payment created the first time, with `201`. The same key with a **different** body answers `400 invalid_idempotent_request`. Keys are up to 256 characters and are scoped to your business and environment. ### Response {#response} ```json { "object": "payment", "id": "pay_01a0b904297873e8b3524a26fdca0ab5", "environment": "production", "status": "pending", "amount": 150000, "paid_amount": 0, "currency": "VND", "code": "HTBF7RDGVQ", "description": "Order #1001", "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41", "bank": { "bin": "970422", "name": "MB Bank", "account_number": "910452658", "account_name": "NGUYEN VAN A" }, "qr": { "payload": "00020101021238530010A0000007270123000697042201099104526580208QRIBFTTA530370454061500005802VN62140810HTBF7RDGVQ63044DEC", "image_url": "https://api.hatang.dev/pay/pay_01a0b904297873e8b3524a26fdca0ab5/qr.svg" }, "checkout_url": "https://app.hatang.dev/pay/pay_01a0b904297873e8b3524a26fdca0ab5", "return_url": "https://shop.example.com/orders/1001", "ipn_url": "https://shop.example.com/hatang/ipn", "customer": { "name": "Tran Thi B", "email": "b@example.com" }, "metadata": { "order_id": "1001" }, "invoice": null, "expires_at": "2026-09-19T10:34:12.856Z", "paid_at": null, "canceled_at": null, "created_at": "2026-09-19T09:34:12.856Z" } ``` Store `id` with your order. The `payment.created` webhook fires (there is no IPN for it). ### Errors | Error | When | What to do | |---|---|---| | `422 validation_error` | A field is missing or wrong; the message names it (`amount: Number must be greater than or equal to 1`) | Fix the request | | `422 validation_error` on `ipn_url` | The URL points at an internal address, or is not `https` in production | Use a public HTTPS URL | | `422 validation_error` "Several bank accounts are linked; pass connection_id" | More than one active account | Send `connection_id` | | `422 no_bank_connection` | No active account, or `connection_id` is not active | Link or re-link the account | | `409 code_taken` | Your `code` is already used by another payment into this account | Use another code | | `400 invalid_idempotent_request` | The key was used with a different body | Use a new key for a new order | | `429 rate_limit_exceeded` | More than 20 Payments requests per second for your business | Retry after the `Retry-After` header | ## 2. Let the payer pay Redirect to `checkout_url`, or show the `qr` and `bank` details yourself. See [How the payer pays](https://hatang.dev/docs/guides/payments/buyer-flow.md). ## 3. Learn the result Wait for the IPN call or a webhook (see [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md)), then fulfil the order when `status` is `paid` or `overpaid` and `paid_amount` covers the order. Never fulfil from the browser's word. ## Statuses ```mermaid stateDiagram-v2 [*] --> pending pending --> paid: full amount pending --> partially_paid: less pending --> overpaid: more partially_paid --> paid: rest arrives with the same code partially_paid --> overpaid: more than the rest paid --> overpaid: another transfer with the same code pending --> expired: expires_at passes expired --> paid: late money pending --> canceled: POST /cancel ``` | `status` | Meaning | Event | |---|---|---| | `pending` | Waiting for the transfer | `payment.created` | | `paid` | The full amount arrived; `paid_at` is set | `payment.succeeded` | | `partially_paid` | Less than the amount arrived. Another transfer with the same code adds up | `payment.partially_paid` | | `overpaid` | More than the amount arrived | `payment.overpaid` | | `expired` | Nothing arrived before `expires_at` | `payment.expired` | | `canceled` | You canceled it while it was `pending` | `payment.canceled` | `payment.succeeded`, `payment.partially_paid` and `payment.overpaid` carry the matching bank `transaction` alongside the payment. ### Edge cases - **Late money.** A transfer that arrives after `expires_at` is still recorded: the payment moves from `expired` to `paid` (or `partially_paid`, `overpaid`) and you are notified. Decide yourself whether to fulfil a late order. - **Partial payments do not expire.** Only a `pending` payment becomes `expired`. A `partially_paid` payment stays `partially_paid` until the rest arrives or you settle it by hand. - **Paying twice.** A second transfer with the code of a `paid` payment makes it `overpaid`, and `paid_amount` is the sum. Refunds happen outside Ha Tang. - **Canceled payments** are no longer matched. A transfer with their code arrives as an unmatched `transaction.received`. - **Expiry timing.** Expiry runs every 30 seconds, so `payment.expired` arrives shortly after `expires_at`. ## The payment code {#payment-code} `code` is a prefix (`HT` by default, changeable per bank account with `config.code_prefix`) plus 8 characters without look-alikes: no `0`, `O`, `1` or `I`. The payer must put it in the transfer description. Anything around it is fine (`CK HTBF7RDGVQ thanh toan`), and case, spaces, punctuation and accents are ignored. The VietQR already carries it, so a payer who scans does not type it. ### Your own code {#your-own-code} Send `code` and that becomes the transfer description instead: your order number, so your bank statement and your orders read the same. ```json { "amount": 275000, "code": "DH1002" } ``` - Letters and digits only, 6 to 20 of them; it is upper-cased. - It must be unused on that bank account, or the call answers `409 code_taken`. Retrying with the same `Idempotency-Key` returns the existing payment instead. - **Longer is safer.** The code is found by looking for it inside the description, so a short common code (`ORDER1`) can match a transfer that merely happens to contain it. Codes with your own prefix (`DH1002`) are specific enough. - **When one code is part of another** (`DH1002` and `DH10024`), the longer one wins the transfer that carries it. ## Retrieve and list payments ```bash curl https://api.hatang.dev/payments/pay_123 -H "Authorization: Bearer $HATANG_API_KEY" curl "https://api.hatang.dev/payments?status=paid,overpaid&limit=50" -H "Authorization: Bearer $HATANG_API_KEY" ``` | Parameter | Meaning | |---|---| | `limit` | 1 to 100, default 20 | | `after` | The `next_cursor` of the previous page | | `status` | One or more statuses, comma-separated | | `connection_id` | One bank account | The list answers `{ "object": "list", "data": [...], "has_more": false, "next_cursor": null }`, newest first. ## Cancel a payment **cURL** ```bash curl -X POST https://api.hatang.dev/payments/pay_123/cancel \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts const { data, error } = await hatang.payments.cancel("pay_123"); ``` **Python** ```python hatang.payments.cancel("pay_123") ``` **PHP** ```php $client->post('/payments/pay_123/cancel'); ``` Only a `pending` payment can be canceled. Any other status answers `409 invalid_state` ("A paid payment cannot be canceled"). ## Record money received another way When money arrived outside the bank feed (cash, or a transfer without the code), record it by hand. The dashboard has the same action: **Mark paid** on the payment page. **cURL** ```bash curl https://api.hatang.dev/payments/pay_123/mark-paid \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "note": "Paid in cash at the counter" }' ``` **Node.js** ```ts await hatang.payments.markPaid("pay_123", { note: "Paid in cash at the counter" }); ``` **Python** ```python requests.post("https://api.hatang.dev/payments/pay_123/mark-paid", headers=HEADERS, json={"note": "Paid in cash at the counter"}, timeout=10).raise_for_status() ``` **PHP** ```php $client->post('/payments/pay_123/mark-paid', ['json' => ['note' => 'Paid in cash at the counter']]); ``` | Field | Meaning | |---|---| | `amount` | The amount received. Defaults to what is still owed (`amount - paid_amount`) | | `note` | Up to 500 characters, kept in the event | It works on `pending`, `partially_paid` and `expired` payments (otherwise `409 invalid_state`). The payment moves to `paid`, `partially_paid` or `overpaid` exactly as if a transfer had arrived, and the webhook event's `data` carries `manual: { by, note, amount }` so you can tell it apart. ## Next steps - [How the payer pays](https://hatang.dev/docs/guides/payments/buyer-flow.md): the checkout page, the QR and your own payment page. - [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md): IPN and webhooks. - [E-invoices](https://hatang.dev/docs/guides/payments/einvoices.md): add an `invoice` block. Source: https://hatang.dev/docs/guides/payments/create-payment --- # E-invoices Ha Tang issues e-invoices (hóa đơn điện tử) under your own tax code, through your own account at the e-invoice provider. Add an `invoice` block to a payment and the invoice is created once the order is paid; or create one yourself for any payment or bank transfer. This page covers connecting the provider, the invoice lifecycle, fixing failures, and reconciling money in against invoices. ## Before you start - A Mắt Bão e-invoice account with API access. The tax code, API user and password are under "Cấu hình API" in the Mắt Bão portal. - At least one registered template and series (mẫu số and ký hiệu) on that account. - An API key with `payments:write`. E-invoices use the Payments scopes and environments. In `development`, connect Mắt Bão's demo sandbox account: invoices are real sandbox invoices but never reach the tax office. ## 1. Connect the provider Do it once per environment, in the dashboard (**Payments settings → Invoice providers**) or through the API. First check the credentials and list the account's templates; nothing is saved: **cURL** ```bash curl https://api.hatang.dev/einvoices/connections/check \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "provider": "matbao", "tax_code": "0109876543", "username": "api-user", "password": "..." }' ``` **Node.js** ```ts const { data: check, error } = await hatang.einvoices.connections.check({ provider: "matbao", tax_code: "0109876543", username: "api-user", password: process.env.MATBAO_PASSWORD!, }); ``` **Python** ```python check = requests.post("https://api.hatang.dev/einvoices/connections/check", headers=HEADERS, json={ "provider": "matbao", "tax_code": "0109876543", "username": "api-user", "password": os.environ["MATBAO_PASSWORD"], }, timeout=30).json() ``` **PHP** ```php $check = json_decode($client->post('/einvoices/connections/check', ['json' => [ 'provider' => 'matbao', 'tax_code' => '0109876543', 'username' => 'api-user', 'password' => getenv('MATBAO_PASSWORD'), ]])->getBody(), true); ``` ```json { "object": "einvoice_connection_check", "ok": true, "provider": "matbao", "seller_tax_code": "0109876543", "seller_name": "CONG TY TNHH VI DU", "templates": [ { "pattern": "1", "serial": "C26TAT", "name": "Hóa đơn giá trị gia tăng", "remaining": 4980 } ] } ``` Then connect with the template you chose. The body: | Field | Type | Required | Default | Meaning | |---|---|---|---|---| | `provider` | string | Yes | – | `matbao` (`GET /einvoices/providers` lists them) | | `tax_code`, `username`, `password` | string | Yes for `matbao` | – | The seller's tax code and the provider API account; stored encrypted | | `pattern` | string, 1 to 6 | Yes | – | Default template for new invoices; must be one of the account's | | `serial` | string, exactly 6 | Yes | – | Default series, e.g. `C26TAT` | | `default_mode` | `draft` \| `publish` | No | `draft` | What happens when a paid order asks for an invoice without `mode`: `draft` stops at the draft, `publish` also sends it for approval | | `is_default` | boolean | No | `true` for the first provider | New invoices go to the default provider | | `import_external` | boolean | No | `false` | Also bring in invoices created directly on the provider's site | | `label` | string, up to 100 | No | – | Your name for the connection | OpenAPI schema: [`POST /einvoices/connections`](https://hatang.dev/docs/reference/api/post-einvoices-connections) ```bash curl https://api.hatang.dev/einvoices/connections \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "provider": "matbao", "tax_code": "0109876543", "username": "api-user", "password": "...", "pattern": "1", "serial": "C26TAT", "default_mode": "publish" }' ``` The answer (`201`) is the connection, with a `callback_url`. Send that URL to Mắt Bão support so that changes made on their side reach Ha Tang at once; without it, Ha Tang checks an invoice still pending an hour after it was created every 15 minutes, for up to 7 days. A draft you keep on purpose (`mode: "draft"`) is not checked: call `POST /einvoices/{id}/sync` to bring in edits made on the provider's site. Credentials are stored encrypted. If the provider starts refusing them, the connection becomes `needs_reauth` and invoices pause until you save new ones with `PATCH /einvoices/connections/{id}`. | Endpoint | Does | |---|---| | `GET /einvoices/providers` | Providers you can connect, with the fields each needs | | `GET /einvoices/connections` / `GET /einvoices/connections/{id}` | Your connections | | `PATCH /einvoices/connections/{id}` | Change the default mode, template, credentials, label | | `POST /einvoices/connections/{id}/default` | Make it the default provider | | `GET /einvoices/connections/{id}/templates` | The templates the account may issue on, read live | | `POST /einvoices/connections/{id}/sync` | Pull changes from the provider now | | `DELETE /einvoices/connections/{id}` | Unlink. Its invoices stay; the next provider becomes the default | ## 2. Add an invoice to a payment Add an `invoice` block when you [create the payment](https://hatang.dev/docs/guides/payments/create-payment.md). Nothing is sent to the provider until the payment is `paid` or `overpaid`. **cURL** ```bash curl https://api.hatang.dev/payments \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Idempotency-Key: order-1002" \ -H "Content-Type: application/json" \ -d '{ "amount": 275000, "description": "Order #1002", "ipn_url": "https://shop.example.com/hatang/ipn", "metadata": { "order_id": "1002" }, "invoice": { "mode": "publish", "template": { "pattern": "1", "serial": "C26TAT" }, "buyer": { "name": "Nguyen Van Khach", "legal_name": "Cong ty TNHH Khach Hang", "tax_code": "0109876543", "address": "1 Trang Tien, Ha Noi", "email": "accounting@example.com" }, "items": [{ "name": "September service plan", "unit": "plan", "quantity": 1, "unit_price": 250000, "vat_rate": 10 }], "payment_method": "CK", "send_email": true } }' ``` **Node.js** ```ts const { data: payment, error } = await hatang.payments.createOrder({ order_id: "1002", amount: 275_000, description: "Order #1002", ipn_url: "https://shop.example.com/hatang/ipn", invoice: { mode: "publish", template: { pattern: "1", serial: "C26TAT" }, buyer: { name: "Nguyen Van Khach", legal_name: "Cong ty TNHH Khach Hang", tax_code: "0109876543", address: "1 Trang Tien, Ha Noi", email: "accounting@example.com" }, items: [{ name: "September service plan", unit: "plan", quantity: 1, unit_price: 250_000, vat_rate: 10 }], payment_method: "CK", send_email: true, }, }); ``` **Python** ```python payment = hatang.payments.create({ "amount": 275000, "description": "Order #1002", "ipn_url": "https://shop.example.com/hatang/ipn", "metadata": {"order_id": "1002"}, "invoice": { "mode": "publish", "template": {"pattern": "1", "serial": "C26TAT"}, "buyer": {"name": "Nguyen Van Khach", "legal_name": "Cong ty TNHH Khach Hang", "tax_code": "0109876543", "address": "1 Trang Tien, Ha Noi", "email": "accounting@example.com"}, "items": [{"name": "September service plan", "unit": "plan", "quantity": 1, "unit_price": 250000, "vat_rate": 10}], "payment_method": "CK", "send_email": True, }, }, idempotency_key="order-1002") ``` **PHP** ```php $payment = json_decode($client->post('/payments', [ 'headers' => ['Idempotency-Key' => 'order-1002'], 'json' => [ 'amount' => 275000, 'description' => 'Order #1002', 'ipn_url' => 'https://shop.example.com/hatang/ipn', 'metadata' => ['order_id' => '1002'], 'invoice' => [ 'mode' => 'publish', 'template' => ['pattern' => '1', 'serial' => 'C26TAT'], 'buyer' => ['name' => 'Nguyen Van Khach', 'legal_name' => 'Cong ty TNHH Khach Hang', 'tax_code' => '0109876543', 'address' => '1 Trang Tien, Ha Noi', 'email' => 'accounting@example.com'], 'items' => [['name' => 'September service plan', 'unit' => 'plan', 'quantity' => 1, 'unit_price' => 250000, 'vat_rate' => 10]], 'payment_method' => 'CK', 'send_email' => true, ], ], ])->getBody(), true); ``` | Field | Type | Required | Default | Meaning | |---|---|---|---|---| | `mode` | `draft` \| `publish` | No | the connection's `default_mode` | `draft` stops at the draft, which you review and send with `POST /einvoices/{id}/publish`; `publish` creates the draft and sends it for approval (signing) | | `template` | object `{ pattern, serial }` | No | the connection's | `pattern`: mẫu số, 1 to 6 characters; `serial`: ký hiệu, exactly 6 | | `buyer` | [buyer](#buyer) | Yes | – | Needs a `name` or a `legal_name` | | `items` | array of [line items](#line-item), 1 to 200 | Yes | – | The lines on the invoice | | `payment_method` | string, up to 50 | No | `TM/CK` | How it was paid, printed on the invoice | | `note` | string, up to 500 | No | – | A note, printed on the invoice | | `send_email` | boolean | No | `false` | Email the issued invoice to the buyer (at `buyer.email`) | OpenAPI schema: [`POST /payments`](https://hatang.dev/docs/reference/api/post-payments), [`POST /einvoices`](https://hatang.dev/docs/reference/api/post-einvoices) > **Caution: Totals** > > Make the invoice total (after VAT) equal the payment `amount`: here 250,000 + 10% = 275,000. Ha Tang does not check it. Each line is rounded to the đồng on its own; when your prices include VAT, give each line's `vat_amount` so the total lands on the đồng. See [Totals and VAT rounding](#totals). ## 3. What happens after payment {#after-payment} Every invoice takes the same two steps, create the draft and send it for approval, each queued and retried on its own: ```mermaid flowchart LR A[waiting_for_payment] -->|paid| B[creating] B --> C[draft] C -->|mode publish| D[publishing] D --> E[issued] E --> F[tax_accepted] E --> G[tax_rejected] B -.-> X[failed] D -.-> X ``` | `invoice.status` | Meaning | |---|---| | `waiting_for_payment` | Not paid yet; nothing sent (payment summary only) | | `creating` | Paid; the draft is being created at the provider | | `draft` | The draft exists. Stops here in `draft` mode | | `publishing` | Sent for approval (being signed) | | `issued` | Signed: it has a `number` and a `lookup_code`; waiting for the tax office | | `tax_accepted` / `tax_rejected` | The tax office's answer (`tax_authority_code` when accepted) | | `failed` | The provider refused a step: `error` says why and `failed_step` which step | | `canceled` / `replaced` / `adjusted` | Changed afterwards, on the provider's site or by a replacement | | `deleted` | A draft that was deleted | The payment's `invoice` field ([its fields](#payment-invoice)) always shows where its invoice stands, and every change is sent as an IPN call and as `invoice.*` webhooks: ```json "invoice": { "requested": true, "mode": "publish", "status": "issued", "id": "einv_01a0b90429f678e68ed7de882720a2b4", "number": "8003", "series": "1C26TAT", "template": { "pattern": "1", "serial": "C26TAT" }, "lookup_code": "HTXAEM9P3AYXSM", "tax_authority_code": null, "pdf_url": "https://…", "sync_state": "in_sync", "issued_at": "2026-09-18T17:00:00.000Z", "error": null, "failed_step": null } ``` If the payment is paid while no provider is connected in that environment, `invoice.status` is `failed` with `id: null` and the error "No e-invoice provider is connected in this environment". The checkout page shows the payer only whether an invoice is coming and, once issued, its number and lookup code. ## Review drafts before they are signed With `mode: "draft"`, the invoice stops at `draft`. You can then: | Call | Does | |---|---| | `PATCH /einvoices/{id}` | Edit `buyer`, `items`, `payment_method`, `note` | | `POST /einvoices/{id}/publish` | Send it for approval | | `DELETE /einvoices/{id}` | Delete the draft | Only a draft can be edited or sent for approval; anything else answers `409 invalid_state`. Even `mode: "publish"` never issues in one call: approval is always a separate, queued step. ## When a step fails Timeouts and provider outages are retried automatically. A refusal (for example an invalid buyer tax code) stops the invoice at `failed` with the provider's message: 1. Fix what the message says. When it failed while creating the draft (`failed_step: "create_draft"`), correct the buyer or the items with `PATCH /einvoices/{id}`. 2. `POST /einvoices/{id}/retry` runs the same step again. 3. `GET /einvoices/{id}/history` lists every status change, oldest first, with which side made it (`source`) and when. ```json { "object": "list", "data": [ { "from": null, "to": "creating", "source": "hatang", "detail": { "mode": "publish", "template": "1/C26TAT" }, "at": "2026-09-18T16:59:58.120Z" }, { "from": "draft", "to": "publishing", "source": "hatang", "detail": null, "at": "2026-09-18T16:59:59.010Z" } ] } ``` ## Invoices without a payment {#invoices-without-a-payment} `POST /einvoices` takes the same fields as the `invoice` block, plus: | Field | Type | Required | Default | Meaning | |---|---|---|---|---| | `payment_id` | string `pay_…` | No | – | Link it to a payment. A payment has at most one invoice; a second answers `409 invalid_state` | | `transaction_id` | string `txn_…` | No | – | Link it to a bank transfer received [without an order](https://hatang.dev/docs/guides/payments/transactions.md) | | `connection_id` | string `icn_…` | No | the default connection | The provider connection that issues it | OpenAPI schema: [`POST /einvoices`](https://hatang.dev/docs/reference/api/post-einvoices) **cURL** ```bash curl https://api.hatang.dev/einvoices \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "publish", "transaction_id": "txn_123", "buyer": { "name": "Nguyen Van Khach" }, "items": [{ "name": "Coffee beans 1 kg", "quantity": 1, "unit_price": 91667, "vat_rate": 8 }] }' ``` **Node.js** ```ts const { data: invoice, error } = await hatang.einvoices.create({ mode: "publish", transaction_id: "txn_123", buyer: { name: "Nguyen Van Khach" }, items: [{ name: "Coffee beans 1 kg", quantity: 1, unit_price: 91_667, vat_rate: 8 }], }); ``` **Python** ```python invoice = requests.post("https://api.hatang.dev/einvoices", headers=HEADERS, json={ "mode": "publish", "transaction_id": "txn_123", "buyer": {"name": "Nguyen Van Khach"}, "items": [{"name": "Coffee beans 1 kg", "quantity": 1, "unit_price": 91667, "vat_rate": 8}], }, timeout=10).json() ``` **PHP** ```php $invoice = json_decode($client->post('/einvoices', ['json' => [ 'mode' => 'publish', 'transaction_id' => 'txn_123', 'buyer' => ['name' => 'Nguyen Van Khach'], 'items' => [['name' => 'Coffee beans 1 kg', 'quantity' => 1, 'unit_price' => 91667, 'vat_rate' => 8]], ]])->getBody(), true); ``` The answer (`201`) is the [invoice object](#einvoice-object) (`object: "einvoice"`) in `creating`, with `buyer`, `items` and the computed `totals` (`subtotal`, `vat`, `total`). Follow it with `GET /einvoices/{id}` or the `invoice.*` webhooks. `GET /einvoices` lists invoices, filtered by `status`, `payment_id` or `sync_state`. ## Replace, cancel and conflicts - **Replace.** An invoice sent to the tax office (`issued`, `tax_accepted`, `tax_rejected`) cannot be canceled or deleted through Ha Tang. `POST /einvoices/{id}/replace` with a `reason`, the corrected `buyer` and `items` creates a replacement: a draft that names the original, then sent for approval. The original becomes `replaced` once the provider has the replacement. - **Delete** a draft (or a failed invoice) with `DELETE /einvoices/{id}`. - **Changes on the provider's site** come back to Ha Tang: signing, the tax office's code, cancellation, replacement, draft edits and deletions. Their events carry `source: "provider"`. Invoices created directly on the provider's site are brought in only when the connection has `import_external: true`. - **Conflicts.** A draft edited both in Ha Tang and on the provider's site gets `sync_state: "conflict"` and the `invoice.conflict` event. Settle it with `POST /einvoices/{id}/resolve` and `{ "keep": "hatang" }` or `{ "keep": "provider" }`. Editing or publishing is refused until then. - **Check now.** `POST /einvoices/{id}/sync` asks the provider about one invoice right away. ## Reconcile money in against invoices Every transfer into your accounts should end up with an invoice, unless it needs none. A transfer counts as *invoiced* when an invoice in force (not failed, deleted, canceled or tax-rejected) points at it, directly or through the payment it paid. Otherwise it is *missing*, unless you marked it *exempt*. ```bash curl "https://api.hatang.dev/einvoices/reconciliation?from=2026-09-01&to=2026-09-30" \ -H "Authorization: Bearer $HATANG_API_KEY" ``` `from` and `to` are days in Vietnam time (`YYYY-MM-DD`), at most 400 days apart; the default is the last 30 days. The answer has `totals` (`money_in`, `invoiced`, `missing`, `exempt`, each with `count` and `amount`), one row per day, and the `missing` and `exempt` transfers. The dashboard shows the same report under **Payments → Invoice reconciliation**. Mark a transfer as needing no invoice (a refund, money between your own accounts), or undo it: ```bash curl https://api.hatang.dev/payments/transactions/txn_123/invoice-exempt \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reason": "Transfer between our own accounts" }' curl -X DELETE https://api.hatang.dev/payments/transactions/txn_123/invoice-exempt \ -H "Authorization: Bearer $HATANG_API_KEY" ``` Owners and admins of a business with an active provider also get a daily email at 08:00 (Vietnam time) listing yesterday's money in that still has no invoice, and an email when an invoice fails, is rejected by the tax office, is in conflict, or has not reached the tax office an hour after it was created. A draft you keep on purpose (`mode: "draft"`) is not mailed about. ## Objects and how they link {#objects} Three objects take part when an order is paid and invoiced: the **payment** you create, the **transaction** the bank reports, and the **e-invoice**. Each points at the others by id, so you can start from whichever one an event hands you. ```mermaid flowchart LR O["Your order"] -->|"metadata.order_id"| P["payment
pay_…"] T["transaction
txn_…"] -->|"payment_id"| P P -->|"invoice.id"| I["einvoice
einv_…"] I -->|"payment_id"| P I -->|"transaction_id
(no payment)"| T I -->|"reference"| M["Invoice at the provider"] ``` | Keep in your order | Comes from | Use it to | |---|---|---| | Payment `id` (`pay_…`) | The `POST /payments` answer | `GET /payments/{id}`; it is `data.id` in every IPN call | | Invoice `id` (`einv_…`) | The payment's `invoice.id` once the invoice exists (`null` before the payment is paid), or the `POST /einvoices` answer | `GET`, `PATCH`, `publish`, `retry`, `replace` on `/einvoices/{id}`; it is `data.id` in `invoice.*` webhooks | | `number`, `series`, `lookup_code` | The invoice, once `issued` | Show the buyer their invoice and where to look it up | | `reference` | The invoice, from the start | Ha Tang's reference for it at the provider; find the invoice on the provider's site by it | - **Through a payment.** Put an `invoice` block in `POST /payments`, or call `POST /einvoices` with `payment_id`. A payment has one invoice in force; a replacement keeps the same `payment_id`, and the payment's `invoice` field shows the one that currently stands. `GET /einvoices?payment_id=pay_…` lists all of them. - **Through a transfer, without a payment.** If you only receive `transaction.received` webhooks, call `POST /einvoices` with that transfer's `transaction_id`. - **Events.** An IPN call carries the payment, with the invoice's state in `data.invoice`. An `invoice.*` webhook carries the whole invoice object, plus `source` (`hatang`, or `provider` when the change was made on the provider's site). ### The invoice request {#invoice-request} The same object, with the same rules, is what you send in four places: | Where | What it takes | |---|---| | `invoice` in `POST /payments` | Everything below | | `POST /einvoices` | Everything below, plus `payment_id`, `transaction_id`, `connection_id` | | `PATCH /einvoices/{id}` (a draft) | `buyer`, `items`, `payment_method`, `note`; what you send replaces what was there; all optional, send only what changes | | `POST /einvoices/{id}/replace` | `reason`, `buyer`, `items`, `payment_method`, `note` | A complete request, a company buyer with two lines (one with its VAT typed in, one not subject to VAT): ```json { "mode": "publish", "template": { "pattern": "1", "serial": "C26TAT" }, "buyer": { "name": "Nguyen Van Khach", "legal_name": "Cong ty TNHH Khach Hang", "tax_code": "0109876543", "address": "1 Trang Tien, Ha Noi", "email": "accounting@example.com", "phone": "0901234567", "buyer_code": "KH-0042" }, "items": [ { "name": "Coffee 1 kg", "code": "CF-1KG", "unit": "kg", "quantity": 1, "unit_price": 45455, "vat_rate": 10, "vat_amount": 4545 }, { "name": "Delivery", "unit": "order", "quantity": 1, "unit_price": 20000, "vat_rate": -1 } ], "payment_method": "CK", "note": "Order #1002", "send_email": true } ``` Its totals: subtotal 65,455, VAT 4,545, total 70,000. | Field | Type | Required | Default | Meaning | |---|---|---|---|---| | `mode` | `draft` \| `publish` | No | the connection's `default_mode` | `draft` stops at the draft, which you review and send with `POST /einvoices/{id}/publish`; `publish` creates the draft and sends it for approval (signing) | | `template` | object `{ pattern, serial }` | No | the connection's | `pattern`: mẫu số, 1 to 6 characters; `serial`: ký hiệu, exactly 6 | | `buyer` | [buyer](#buyer) | Yes | – | Needs a `name` or a `legal_name` | | `items` | array of [line items](#line-item), 1 to 200 | Yes | – | The lines on the invoice | | `payment_method` | string, up to 50 | No | `TM/CK` | How it was paid, printed on the invoice | | `note` | string, up to 500 | No | – | A note, printed on the invoice | | `send_email` | boolean | No | `false` | Email the issued invoice to the buyer (at `buyer.email`) | OpenAPI schema: [`POST /payments`](https://hatang.dev/docs/reference/api/post-payments), [`POST /einvoices`](https://hatang.dev/docs/reference/api/post-einvoices) #### Buyer {#buyer} Give `name`, `legal_name` or both. For a company, `legal_name` is the company and `name` the person who bought; for a person, `name` alone. | Field | Type | Required | Default | Meaning | |---|---|---|---|---| | `name` | string, up to 200 | One of the two | – | The person buying, printed as "Họ tên người mua hàng" (buyer's name) | | `legal_name` | string, up to 400 | One of the two | – | The company's registered name, printed as "Tên đơn vị" (company name). Leave it out for a person | | `tax_code` | 10 to 14 digits or dashes | No | – | The buyer's tax code (mã số thuế), e.g. `0109876543` or `0109876543-001` | | `address` | string, up to 400 | No | – | The buyer's address | | `email` | email | No | – | Where `send_email` sends the invoice | | `phone` | string, up to 20 | No | – | The buyer's phone number | | `buyer_code` | string, up to 50 | No | – | Your own customer code, printed on the invoice | OpenAPI schema: [`POST /payments`](https://hatang.dev/docs/reference/api/post-payments), [`POST /einvoices`](https://hatang.dev/docs/reference/api/post-einvoices) #### Line item {#line-item} | Field | Type | Required | Default | Meaning | |---|---|---|---|---| | `name` | string, 1 to 500 | Yes | – | What was sold | | `quantity` | number above 0 | Yes | – | Decimals allowed, e.g. `1.5` kg | | `unit_price` | number, 0 or more | Yes | – | Price of one unit **before VAT**, in đồng. Decimals allowed | | `vat_rate` | `10` \| `8` \| `5` \| `0` \| `-1` \| `-2` | No | `10` | `-1`: not subject to VAT (KCT); `-2`: not declared (KKKNT) | | `vat_amount` | whole đồng, 0 or more | No | worked out from `vat_rate` | The line's VAT, used as given. Only with a `vat_rate` above 0. See [Totals and VAT rounding](#totals) | | `discount` | number, 0 or more | No | `0` | Discount for the whole line, in đồng before VAT | | `unit` | string, up to 50 | No | – | Unit, e.g. `kg`, `plan` | | `code` | string, up to 50 | No | – | Your product code (SKU) | OpenAPI schema: [`POST /payments`](https://hatang.dev/docs/reference/api/post-payments), [`POST /einvoices`](https://hatang.dev/docs/reference/api/post-einvoices) In the invoice object every line comes back with what you sent plus three amounts: `amount` (before VAT), `vat_amount` and `total`. ### Totals and VAT rounding {#totals} The đồng has no smaller unit, so every line is rounded on its own, and the invoice adds up the rounded lines. That is also what the provider prints. | Amount | Worked out as | |---|---| | Line `amount` | `quantity × unit_price − discount`, rounded to the đồng | | Line `vat_amount` | `vat_amount` when you give it; otherwise `amount × vat_rate / 100`, rounded; `0` when `vat_rate` is `0`, `-1` or `-2` | | Line `total` | `amount + vat_amount` | | `totals.subtotal` / `totals.vat` / `totals.total` | The sum of the lines' `amount` / `vat_amount` / `total` | Halves round up. Ha Tang does not compare the invoice total with the payment `amount`: make them equal yourself. **When prices include VAT.** Shops usually know what the buyer pays, VAT included, and work the price before VAT back from it. Rounding that price and then the VAT can land a đồng off. 50,000 đồng at 10%: | | `unit_price` | `vat_amount` | `total` | |---|---|---|---| | VAT worked out by Ha Tang | 45,455 | 4,546 (45,455 × 10%, rounded) | **50,001** | | VAT given as `vat_amount` | 45,455 | 4,545 | **50,000** | So give each line's `vat_amount` as what is left of the price paid once the price before VAT is rounded: ```ts // gross: what the buyer pays for the whole line, VAT included, in đồng. function lineFromGross(name: string, quantity: number, gross: number, vatRate: 10 | 8 | 5) { const unit_price = Math.round(gross / quantity / (1 + vatRate / 100)); const amount = Math.round(quantity * unit_price); return { name, quantity, unit_price, vat_rate: vatRate, vat_amount: gross - amount }; } lineFromGross("Coffee 1 kg", 1, 50_000, 10); // { name: "Coffee 1 kg", quantity: 1, unit_price: 45455, vat_rate: 10, vat_amount: 4545 } ``` Rules for `vat_amount`: - Whole đồng, 0 or more. `4545.5` is refused with `422 validation_error`; nothing is rounded for you. - Only on a line whose `vat_rate` is above 0; on a `0`, `-1` or `-2` line it is refused with `422 validation_error`. - Ha Tang does not check it against `vat_rate`; keep it to the rounding difference you need. - It is sent to the provider as the line's VAT, and kept when the invoice is read back from the provider, so a later sync or an edit on the provider's site does not turn it into a conflict. - In the dashboard, the **VAT amount** field on **New invoice** takes the same value, written `4545`, `4.545` or `4,545`. ### The einvoice object {#einvoice-object} What `POST /einvoices` and `GET /einvoices/{id}` answer, and the `data` of every `invoice.*` webhook. The invoice from the request above (first line only), paid and issued: ```json { "object": "einvoice", "id": "einv_01a178887ba6771caef28863e6f55879", "environment": "production", "connection_id": "icn_01a178887b357a9c8bc4ad098f6b8855", "provider": "matbao", "payment_id": "pay_01a178887b59783cb5f4f18f06071ed1", "transaction_id": null, "origin": "hatang", "mode": "publish", "status": "issued", "sync_state": "in_sync", "version": 1, "buyer": { "name": "Nguyen Van Khach", "legal_name": "Cong ty TNHH Khach Hang", "tax_code": "0109876543", "address": "1 Trang Tien, Ha Noi", "email": "accounting@example.com" }, "items": [ { "name": "Coffee 1 kg", "unit": "kg", "quantity": 1, "unit_price": 45455, "vat_rate": 10, "amount": 45455, "vat_amount": 4545, "total": 50000 } ], "totals": { "subtotal": 45455, "vat": 4545, "total": 50000 }, "currency": "VND", "payment_method": "CK", "note": null, "template": { "pattern": "1", "serial": "C26TAT" }, "reference": "HT45MSKX4UY3AA", "provider_invoice_id": "4/AUx/syzogcoBsIReMUk6…", "number": "8001", "series": "1C26TAT", "lookup_code": "HT45MSKX4UY3AA", "tax_authority_code": null, "pdf_url": "https://…", "issued_at": "2026-10-25T17:00:00.000Z", "related_invoice_id": null, "relation": null, "conflict": null, "error": null, "failed_step": null, "created_at": "2026-10-26T14:06:12.895Z", "updated_at": "2026-10-26T14:06:13.004Z" } ``` | Field | Type | Can be `null` | Meaning | |---|---|---|---| | `object` | string | No | Always `einvoice` | | `id` | string | No | `einv_…` | | `environment` | `development` \| `production` | No | The environment of the API key that created it | | `connection_id` | string | No | The provider connection that issues it (`icn_…`) | | `provider` | string | Yes | The provider, e.g. `matbao` | | `payment_id` | string | Yes | The payment it is for | | `transaction_id` | string | Yes | The bank transfer it is for, when there is no payment | | `origin` | `hatang` \| `provider` | No | `hatang` when created through Ha Tang; `provider` when brought in from the provider's site (`import_external`) | | `mode` | `draft` \| `publish` | No | As requested | | `status` | string | No | Where it stands: see [What happens after payment](#after-payment) | | `sync_state` | `in_sync` \| `pending_push` \| `conflict` | No | `pending_push` while a change is on its way to the provider; `conflict` when the draft was edited on both sides | | `version` | integer | No | Goes up by one with every change made through Ha Tang | | `buyer` | [buyer](#buyer) | No | As sent; only the fields you sent | | `items` | array of [line items](#line-item) | No | As sent, each line with its computed `amount`, `vat_amount` and `total` | | `totals` | object `{ subtotal, vat, total }` | No | In đồng | | `currency` | string | No | `VND` | | `payment_method`, `note` | string | Yes | As sent | | `template` | object `{ pattern, serial }` | No | The template and series it is issued on | | `reference` | string, up to 20 | No | Ha Tang's reference for it at the provider | | `provider_invoice_id` | string | Yes | The provider's own id, once the draft exists | | `number` | string | Yes | The invoice number, once `issued` | | `series` | string | Yes | Template and series together, e.g. `1C26TAT` | | `lookup_code` | string | Yes | What the buyer types on the provider's lookup page | | `tax_authority_code` | string | Yes | The tax office's code, once `tax_accepted` | | `pdf_url` | URL | Yes | The provider's PDF, once there is one | | `issued_at` | ISO 8601 string | Yes | The date on the invoice | | `related_invoice_id` | string | Yes | On a replacement: the invoice it replaces | | `relation` | `replaces` \| `adjusts` | Yes | On a replacement (`replaces`) or an adjustment (`adjusts`) | | `conflict` | object | Yes | With `sync_state: "conflict"`: the provider's version, under `content` | | `error` | string | Yes | Why it failed | | `failed_step` | `create_draft` \| `update_draft` \| `publish` \| `delete_draft` | Yes | The step that failed | | `created_at`, `updated_at` | ISO 8601 string | No | | OpenAPI schema: [`GET /einvoices/{id}`](https://hatang.dev/docs/reference/api/get-einvoices-by-id) ### The payment's invoice summary {#payment-invoice} The payment object keeps a short copy of its invoice in `invoice`, so an IPN handler sees the invoice without another call. It is `null` when no invoice was asked for and none exists. | Field | Type | Can be `null` | Meaning | |---|---|---|---| | `requested` | boolean | No | `true` when the payment was created with an `invoice` block | | `mode` | `draft` \| `publish` | Yes | As requested | | `status` | string | No | As on the invoice, plus `waiting_for_payment` before the payment is paid. `failed` with `id: null` when it was paid while no provider was connected | | `id` | string | Yes | The invoice's `einv_…` id, `null` until it exists | | `number`, `series`, `template`, `lookup_code`, `tax_authority_code`, `pdf_url`, `sync_state`, `issued_at`, `error`, `failed_step` | as on the invoice | Yes | As on [the invoice](#einvoice-object); `null` until the invoice exists | OpenAPI schema: [`GET /payments/{id}`](https://hatang.dev/docs/reference/api/get-payments-by-id) To read the lines and totals, fetch the invoice: `GET /einvoices/{invoice.id}`. ## Errors | Error | When | |---|---| | `422 validation_error` | A field is wrong (buyer without a name, bad tax code, more than 200 items), or the template is not registered on the account | | `422 missing_required_field` | A provider credential is missing | | `422 invoice_provider_error` | The provider refused the request; the message says why | | `409 invalid_state` | No provider connected, the payment already has an invoice, the invoice is not in a state that allows this call, or a conflict must be resolved first | | `403 environment_mismatch` | The provider is not available in this environment | | `503 provider_unavailable` | The provider did not answer; try again | ## Next steps - [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md): the IPN calls that carry the invoice. - [Webhooks](https://hatang.dev/docs/guides/payments/webhooks.md): every `invoice.*` event. - [Receive transfers without orders](https://hatang.dev/docs/guides/payments/transactions.md). Source: https://hatang.dev/docs/guides/payments/einvoices --- # Environments and sandbox Payments is the only Ha Tang product with environments. Every business has a `development` and a `production` environment, and their payments data is **completely separate**: bank accounts, payments, transactions, e-invoice providers, invoices and integration keys. API keys and webhook endpoints are shared by both. This page explains how to pick an environment and how to test the whole flow without real money. ## What differs | | `development` | `production` | |---|---|---| | Bank | The development bank (`simulator`): confirmation code `000000`, transfers simulated with an API call | Your real bank account | | E-invoices | The provider's sandbox (Mắt Bão demo); nothing reaches the tax office | Real invoices under your tax code | | Simulated transfers | `POST /payments/simulate/transfer`, plus a button on the checkout page and on the payment page in the dashboard | Not available (`403 environment_mismatch`) | | Integration keys | `ak_test_…` / `sk_test_…` | `ak_…` / `sk_…` | | Chosen with | `Hatang-Environment: development` | No header (the default), or `Hatang-Environment: production` | ## Choose the environment per request {#choose-the-environment} Send the `Hatang-Environment` header on each request: | Header | Result | |---|---| | `Hatang-Environment: development` | The request reads and writes development data | | `Hatang-Environment: production` or no header | Production | | Any other value | `422 validation_error`: "Hatang-Environment must be development or production" | Every response repeats the environment it used in its own `Hatang-Environment` header, and every payments object (payment, transaction, bank connection, invoice) has an `environment` field. **cURL** ```bash curl https://api.hatang.dev/payments \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" ``` **Node.js** ```ts import { Hatang } from "@hatang/sdk"; // The option is sent as the Hatang-Environment header on every request. // Without it, the SDK reads HATANG_ENVIRONMENT from the process environment. const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev", environment: "development", }); const { data } = await hatang.payments.list(); ``` **Python** ```python import os from hatang import Hatang # environment= sends the Hatang-Environment header on every call dev = Hatang(os.environ["HATANG_API_KEY"], environment="development") payments = dev.payments.list() # development payments only ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->get('/payments', ['headers' => [ 'Authorization' => 'Bearer ' . getenv('HATANG_API_KEY'), 'Hatang-Environment' => 'development', ]]); echo $res->getHeaderLine('Hatang-Environment'); // development ``` > **Tip** > > Read the environment from configuration (`HATANG_ENVIRONMENT=development` on your staging server, nothing in production) rather than hard-coding it, so the same code runs in both. > **Note: Older development keys** > > A key that starts with `ht_test_` is tied to development: without the header it uses development, and asking it for production answers `403 environment_mismatch`. An id from one environment is unknown in the other: fetching a development payment with production selected answers `404 not_found`. ## In the dashboard The bar at the top of every Payments page shows the current environment and switches it. Production is shown in red, and switching to it asks for confirmation. Other products have no switch. ## The development bank Link it with `{ "provider": "simulator" }` and confirm with the code `000000` (see [Quickstart](https://hatang.dev/docs/guides/payments/quickstart.md)). By default its QR codes name MB Bank and its account number is made up (it starts with `SIM`), so no real bank resolves it. To see a real VietQR for your own bank, pass `bank_code` and your account number: ```json { "provider": "simulator", "bank_code": "BIDV", "account_number": "96247001234", "account_name": "NGUYEN VAN A" } ``` `bank_code` takes the usual short codes: `MB` (the default), `BIDV`, `ACB`, `VCB`, `ICB`, `TCB`, `VPB`, `TPB`, `STB` and the other VietQR banks. With your real account number, a payer's banking app really does transfer money to you. No bank is integrated in development, though, so Ha Tang does not see that money arrive: the payment only moves when you simulate the transfer or mark it paid. ## Simulate a transfer {#simulate-a-transfer} **cURL** ```bash # Pay a payment in full curl https://api.hatang.dev/payments/simulate/transfer \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Content-Type: application/json" \ -d '{ "payment_id": "pay_123" }' # A partial transfer curl https://api.hatang.dev/payments/simulate/transfer \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Content-Type: application/json" \ -d '{ "payment_id": "pay_123", "amount": 50000 }' ``` **Node.js** ```ts await hatang.payments.simulateTransfer({ payment_id: "pay_123" }); // in full await hatang.payments.simulateTransfer({ payment_id: "pay_123", amount: 50_000 }); // partial // A transfer with no order, for the transfers-only flow: await hatang.payments.simulateTransfer({ connection_id: "bcn_123", amount: 99_000, content: "DH20250919 chuyen tien" }); ``` **Python** ```python HEADERS = {"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}", "Hatang-Environment": "development"} requests.post("https://api.hatang.dev/payments/simulate/transfer", headers=HEADERS, json={"payment_id": "pay_123", "amount": 50000}, timeout=10).raise_for_status() ``` **PHP** ```php $client->post('/payments/simulate/transfer', [ 'headers' => [ 'Authorization' => 'Bearer ' . getenv('HATANG_API_KEY'), 'Hatang-Environment' => 'development', ], 'json' => ['payment_id' => 'pay_123', 'amount' => 50000], ]); ``` | Field | Meaning | |---|---| | `payment_id` | The payment to pay. Fills in the bank account, the amount still owed and the code | | `connection_id` | The bank account, for a transfer with no payment. Give `payment_id` or `connection_id` | | `amount` | Another amount, to test partial payments and overpayments. Defaults to what is still owed | | `content` | The transfer description as the bank would report it. Defaults to `CHUYEN TIEN ` | | `direction` | `in` (default) or `out` | | `reference` | The bank reference number. Reuse one to simulate the bank pushing the same transfer twice | | `counterparty_name` | The payer's name. Defaults to `NGUYEN VAN KHACH` | The call answers `202`: ```json { "object": "simulated_transfer", "reference": "SIM1758274452930A1B2C3", "amount": 50000, "direction": "in", "description": "CHUYEN TIEN HTBF7RDGVQ", "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41", "duplicate": false } ```
The checkout page in development, with a button that simulates the transfer
In development, the checkout page has a button that simulates the transfer
A simulated transfer goes through **exactly the same processing** as a real bank's: matching, events, IPN and webhooks. Your handlers receive what they will receive in production. ### Errors | Error | When | |---|---| | `403 environment_mismatch` | The request is in production | | `422 validation_error` | Neither `payment_id` nor `connection_id`, or the account is not a development bank account | | `409 invalid_state` | The bank account is not `active` | | `422 missing_required_field` | No `amount` and nothing is owed on the payment | ## Test scenarios | Scenario | How | |---|---| | Paid in full | Simulate with `payment_id` only | | Partial, then completed | Simulate `amount` below the total, then simulate again without `amount` | | Overpaid | Simulate `amount` above the total | | Expired | Create with `expires_in: 60`, wait a couple of minutes, check for `payment.expired` | | Money after expiry | Simulate on the expired payment: it becomes `paid` | | Payer forgot the code | Simulate with `connection_id` and a `content` without the code | | Duplicate bank push | Simulate twice with the same `reference` and `amount`: the second answers `duplicate: true` and changes nothing | | Your endpoint is down | Point `ipn_url` at a URL that answers `500`, then read `GET /payments/{id}/ipn-deliveries` | > **Danger** > > Your handlers must check `environment`. A `development` payment must never unlock a real order. ## Next steps - [Link a bank account](https://hatang.dev/docs/guides/payments/bank-connections.md) in production. - [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md). - [Go live](https://hatang.dev/docs/guides/payments/go-live.md): the switch to production, step by step. Source: https://hatang.dev/docs/guides/payments/environments --- # Go live When your integration works end to end in `development`, a few steps move it to production: link your real bank account, connect your real e-invoice account, switch keys and environment, make sure your handlers are strict, then test with a small real transfer. This page walks through each step and ends with what to watch once you are live. ## Before you start Make sure the whole flow works in development: - A payment created from your checkout reaches `paid` after a [simulated transfer](https://hatang.dev/docs/guides/payments/environments.md#simulate-a-transfer). - Your IPN handler verifies the signature, updates the order once, and answers `2xx`. - Partial payments, overpayments, expiry and late money behave the way you want (see the [test scenarios](https://hatang.dev/docs/guides/payments/environments.md)). - If you issue invoices, a sandbox invoice reaches `issued`. Nothing carries over between environments: bank accounts, matching settings, payments, invoice providers and integration keys all have to exist in production too. ## 1. Link your real bank account Switch the dashboard to production (the environment bar on top of the Payments pages, **Switch to production**), then open **Payments settings → Bank accounts → Add bank account** and pick your bank: | Bank | What you need | |---|---| | MB Bank | The account number, the holder's name as MB has it, and the national ID or tax code the account was opened with. MB texts a code to the holder's registered phone | | BIDV | Register BIDV's balance notification service for the account at BIDV, then add the account. It activates when BIDV sends its first message | | ACB | The account number. The holder then consents on ACB's page; the consent lasts up to 180 days | | Pay2S | The account linked on Pay2S, a Hook pointing at the URL shown in the form, and the Hook's token | The details are in [Bank connections](https://hatang.dev/docs/guides/payments/bank-connections.md). Then set the **matching settings** for the production account again: the payment code prefix, your own order code format, and which transfers `transaction.received` reports. > **Caution** > > The person who receives the bank's code or consents on the bank's page must be the account holder, or someone they authorized. Plan the link for a time they are available. ## 2. Connect your e-invoice provider If you issue invoices, open **Payments settings → Invoice providers** in production and connect your real Mắt Bão account: the tax code, API user and password from "Cấu hình API" in the Mắt Bão portal. Pick the template and series registered for your business, and choose what happens when a paid order asks for an invoice: stop at the draft, or send it for approval. Then send the connection's **callback URL** to Mắt Bão support, so that changes made on their side reach Ha Tang at once. See [E-invoices](https://hatang.dev/docs/guides/payments/einvoices.md). ## 3. Switch keys and environment | Setting | Development | Production | |---|---|---| | Environment | `Hatang-Environment: development` or SDK `environment: "development"` | Remove the header and the option (or send `production`) | | IPN secret key | `sk_test_…` | `sk_…` from **Payments settings → Integration keys** in production | | Webhook secret | `whsec_…` | The same endpoint serves both environments; keep its secret | | API key | `ht_…` with `payments:read`, `payments:write` | The same key works; create a separate one per server if you prefer | Keep these values in configuration, not in code: ```bash HATANG_API_KEY=ht_123... HATANG_SECRET_KEY=sk_123... # production secret key, for IPN HATANG_WEBHOOK_SECRET=whsec_123... # webhook endpoint secret # HATANG_ENVIRONMENT unset in production ``` ## 4. Check your endpoints - Your `ipn_url` and webhook endpoint are **https** on the public internet, answer `2xx` within 10 seconds (15 for webhooks), and do not redirect. - They read the **raw body** for verification (no JSON middleware before it). - The webhook endpoint is subscribed to the events you need, for example `payment.succeeded`, `payment.overpaid`, `transaction.received`, `bank_connection.revoked`, `invoice.failed`. - Trust the signature, not the caller's IP address. ## 5. Make your handlers strict Your handler must: 1. Verify the signature with the **production** key, and reject anything that does not verify. 2. Check `environment` is `production`; a development payment must never unlock a real order. 3. Compare `paid_amount` with the order total. 4. Dedupe on the IPN `id` or the webhook `svix-id`. 5. Move the order forward only, whatever order the calls arrive in. Before you hand over goods after the payer returns to `return_url`, confirm on the server with `GET /payments/{id}` or the SDK's `payments.verify()`. ## 6. Test with a small real transfer 1. Create a payment in production for a small amount, for example 10,000 VND, with your real `ipn_url`. 2. Pay it from your own banking app by scanning the QR. 3. Check that the payment becomes `paid` (for ACB, allow up to 10 minutes: Ha Tang reads its history on that cycle), that your IPN arrived (`GET /payments/{id}/ipn-deliveries`), and that the transfer is listed under **Payments → Transactions**. 4. If the order asked for an invoice, check it reaches `issued` and has a number. 5. Then test the mistakes: pay a second small payment without the code and confirm that `transaction.received` arrives with `payment_id: null`. The money lands in your own account; nothing needs refunding through Ha Tang. ## 7. Watch it once you are live | What to watch | Where | |---|---| | IPN calls that keep failing | `GET /payments/{id}/ipn-deliveries` (`status: "exhausted"`), and the payment page | | Webhook deliveries that fail | `GET /webhooks/{id}/deliveries`, and **Webhooks** in the dashboard | | A bank account that stops working | `bank_connection.revoked` and `bank_connection.needs_reauth` webhooks; the account's status and last transaction under **Bank accounts** | | ACB consent expiry | The connection's `consent.expires_at`; link the account again before it passes | | Stuck calls to banks or the invoice provider | **Payments → Integration health** | | Invoices that failed or were rejected | `invoice.failed`, `invoice.tax_rejected` webhooks, and the alert emails Ha Tang sends to owners and admins | | Money in without an invoice | **Payments → Invoice reconciliation**, and the daily email at 08:00 | | Transfers the bank never reported | [Import a statement](https://hatang.dev/docs/guides/payments/bank-connections.md#statement-import) for the missing days | ## Launch checklist - [ ] The real bank account is linked and `active` in production. - [ ] The production account's matching settings are set. - [ ] The e-invoice provider is connected in production, with the right template, and Mắt Bão has the callback URL. - [ ] The production **secret key** and the webhook secret are configured on your server. - [ ] The `Hatang-Environment: development` header (or the SDK option) is gone from production. - [ ] Your handlers check the signature, `environment`, the amount and duplicates, and move orders forward only. - [ ] The IPN and webhook URLs are public `https` and answer `2xx` within 10 seconds. - [ ] A small real transfer went through end to end. - [ ] Someone watches failed deliveries, `bank_connection.*` events and invoice alerts. ## Next steps - [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md). - [Bank connections](https://hatang.dev/docs/guides/payments/bank-connections.md). - [Errors](https://hatang.dev/docs/reference/errors.md) and [Limits](https://hatang.dev/docs/reference/limits.md). Source: https://hatang.dev/docs/guides/payments/go-live --- # Overview **Ha Tang Payments** takes VietQR bank transfers straight into **your own bank account**, tells your server the moment the money arrives, and can issue the e-invoice (hóa đơn điện tử) for the order. Ha Tang never holds the money. The payer transfers to your account; Ha Tang reads the account's balance changes and matches them to your orders. Money does not sit in a wallet waiting to be paid out, and there is no intermediary account in between. ## Who it is for - **Online shops:** a QR code for every order, confirmed automatically when the customer pays. - **Shops and counters:** one printed QR code, and a notification for every transfer that carries one of your order codes. - **SaaS and subscriptions:** collect a plan's price and issue the e-invoice automatically once it is paid. - **Platforms and accounting software:** sync bank transactions and invoices through the API and webhooks. ## What the payer sees
The Ha Tang checkout page waiting for payment
The checkout page: VietQR, transfer details and a countdown
The Ha Tang checkout page after a successful payment
It updates by itself when the money arrives, with the e-invoice number
## How it works ```mermaid sequenceDiagram autonumber participant S as Your server participant H as Ha Tang participant P as Payer participant B as Your bank participant M as Mắt Bão (e-invoices) S->>H: POST /payments H-->>S: payment (code, qr, checkout_url) S->>P: Redirect to checkout_url or show the QR P->>B: Bank transfer (NAPAS 247) B->>H: Balance change H->>H: Match the payment code H->>S: IPN / webhook payment.succeeded opt E-invoice requested H->>M: Create the draft, send it for approval M-->>H: Invoice number, lookup code H->>S: IPN / webhook invoice.issued end ``` 1. You link your bank account once (dashboard → **Payments settings → Bank accounts**). 2. You create a **payment** for each order, or simply show your account and let Ha Tang report every transfer. 3. The payer scans the VietQR with any Vietnamese banking app, or transfers by hand with the **payment code** in the description. 4. The bank tells Ha Tang. Ha Tang matches the code to your payment and calls your server: an **IPN** to the URL you gave for that payment, and **webhooks** for your whole business. 5. If you asked for one, Ha Tang creates the e-invoice at your provider, sends it for approval, and returns its number and lookup code. ## Banks Each bank links in its own way. The banks you can pick in each environment are listed on **Payments settings → Bank accounts → Add bank account**. | Bank | `provider` | How you link it | How Ha Tang learns about transfers | Environment | |---|---|---|---|---| | MB Bank | `mbbank` | MB texts a code to the account holder | Real-time balance notifications | `production` | | BIDV | `bidv` | You register BIDV's balance notification service at BIDV; the account activates with BIDV's first message | Real-time balance notifications | `production` | | ACB | `acb` | The holder consents on ACB's own page (Open API under Circular 64/2024/TT-NHNN) | Account history, read every 10 minutes | `production` | | Pay2S | `pay2s` | You create a Hook on Pay2S and paste its token; the account activates with the first push | Real-time pushes from Pay2S | `production` | | Development bank | `simulator` | Code `000000` | Simulated with an API call | `development` | Pay2S reads accounts at Vietcombank, VietinBank, Techcombank, BIDV, ACB, MB Bank and TPBank. Whatever the bank, you can also recover transfers by [importing a statement](https://hatang.dev/docs/guides/payments/bank-connections.md#statement-import). ## E-invoice providers | Provider | `development` | `production` | |---|---|---| | Mắt Bão | The provider's demo sandbox; nothing reaches the tax office | Real invoices under your tax code | ## Three ways to integrate | | Transfers only | Orders | Orders + e-invoice | |---|---|---|---| | What you create per order | Nothing | `POST /payments` | `POST /payments` with `invoice` | | What the payer writes in the description | Your own order code (e.g. `DH1001`) | The payment code (e.g. `HTBF7RDGVQ`, or your own `code`) | Same | | What Ha Tang tells you | `transaction.received` with the code it found | Paid, partially paid, overpaid, expired | The same, plus the invoice number | | Hosted checkout page and expiry | No | Yes | Yes | | E-invoice | Only if you create one (`POST /einvoices` with `transaction_id`) | No | Created automatically once paid | | Guide | [Receive transfers](https://hatang.dev/docs/guides/payments/transactions.md) | [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md) | [E-invoices](https://hatang.dev/docs/guides/payments/einvoices.md) | You can mix them on the same bank account. A transfer is always matched against your payments first, so a payment is never reported twice. ## What you get - **Money goes straight to your account.** No wallet, no payout delay. - **Confirmation in seconds** on banks that push balance changes. - **Automatic matching** of payment codes, with partial payments, overpayments, expiry and late money handled. - **E-invoices issued automatically** once an order is paid, and a reconciliation report of money in without an invoice. - **A hosted checkout page** that updates live and needs no login. - **Two separate environments**, with a development bank to test the whole flow. - **Signed notifications:** HMAC-SHA256 for IPN calls, Svix signatures for webhooks, and up to 8 delivery attempts each. - **A Node.js SDK** whose verification helpers also run on Bun, Deno, Vercel and Cloudflare Workers. ## Next steps - [Quickstart](https://hatang.dev/docs/guides/payments/quickstart.md): take your first payment in development in about ten minutes. - [Environments and sandbox](https://hatang.dev/docs/guides/payments/environments.md): how development and production differ. - [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md): every field, status and edge case. - [REST API reference](https://hatang.dev/docs/reference/api.md): every endpoint. Source: https://hatang.dev/docs/guides/payments/overview --- # Receive payment results Ha Tang tells your server about payments in three ways: an **IPN** call to the `ipn_url` of one payment, **webhooks** for everything in your business, and **polling** the API whenever you want. This page compares them, shows exactly what an IPN call looks like, and lists what your handler must check. ## Choose a channel | | IPN | Webhooks | Polling | |---|---|---|---| | Covers | One payment (the `ipn_url` given when creating it) | Everything in your business, both environments | Anything, any time | | Sent on | Every `payment.*` change except `created`, and every `invoice.*` change of that payment | The event types the endpoint subscribes to | – | | Body | `{ id, event_id, type, environment, created_at, data: }` | `{ type, created_at, data }` | The object | | Signature | `Hatang-Signature`: HMAC-SHA256 of the body with your **secret key** | Svix: `svix-id`, `svix-timestamp`, `svix-signature` | Your API key | | Retries | 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h (8 attempts) | 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h (8 attempts) | – | | Timeout | 10 s | 15 s | – | | Delivery log | `GET /payments/{id}/ipn-deliveries` | `GET /webhooks/{id}/deliveries` | – | Most shops use the IPN for their orders, because it carries the whole payment and needs no setup beyond `ipn_url`. Use webhooks for transfers without orders (`transaction.received`), bank account events, and anything you want for your whole business. Polling is the fallback. ## Rules for your endpoint - IPN and webhook URLs must be **https** and on the public internet. Addresses such as `localhost`, `10.x`, `192.168.x` or `169.254.169.254` are refused, when the URL is saved and again when Ha Tang connects. - A call counts as delivered when your endpoint answers **2xx**. Redirects are not followed, so a `301` or `302` is a failure. - Answer quickly and do the work afterwards (a queue). A slow answer is a failure and the call is sent again. ## What your handler must do 1. **Verify the signature on the raw body**, before parsing the JSON ([IPN](https://hatang.dev/docs/guides/payments/verify-ipn.md), [webhooks](https://hatang.dev/docs/guides/payments/webhooks.md)). 2. **Dedupe.** The IPN `id` (and `event_id`) and the webhook `svix-id` are the same on every retry of the same call. 3. **Trust the status, not the order of arrival.** Calls can arrive out of order or twice. Read `data.status` (or fetch `GET /payments/{id}`), and only ever move your order forward. 4. **Check the amount and the environment.** Compare `data.paid_amount` with your order, and check `environment`: a development payment must never fulfil a real order. ```ts // Move the order forward only; ignore anything that would move it back. const RANK = { pending: 0, expired: 1, partially_paid: 2, paid: 3, overpaid: 3, canceled: 0 } as const; async function applyIpn(ipn: IpnEvent) { if (ipn.environment !== process.env.EXPECTED_ENVIRONMENT) return; const order = await db.orders.find(ipn.data.metadata.order_id); if (!order || RANK[ipn.data.status] <= RANK[order.paymentStatus]) return; if ((ipn.data.status === "paid" || ipn.data.status === "overpaid") && ipn.data.paid_amount >= order.total) { await db.orders.markPaid(order.id, ipn.data.id); } else { await db.orders.setPaymentStatus(order.id, ipn.data.status); } } ``` ## An IPN call This is what your endpoint receives: ```http POST /hatang/ipn HTTP/1.1 Content-Type: application/json User-Agent: Hatang-IPN/1.0 Hatang-Access-Key: ak_Example0AccessKey0000000 Hatang-Signature: 8f0c3b1e5d7a9c2f4e6b8a0d1c3e5f7a9b2d4f6e8a0c2e4f6b8d0a2c4e6f8b0d Hatang-Signature-Algorithm: HMAC-SHA256 Hatang-Event: payment.succeeded Hatang-Delivery-Id: ipn_01a0b90429d87cf89e37fc2f66040ec5 Hatang-Environment: production {"id":"ipn_01a0b90429d87cf89e37fc2f66040ec5","event_id":"de_01a0b90429b57b41a2ef3510d6bf8036","type":"payment.succeeded","environment":"production","created_at":"2026-09-19T09:34:12.952Z","data":{"object":"payment","id":"pay_01a0b904297873e8b3524a26fdca0ab5","status":"paid","amount":150000,"paid_amount":150000,"code":"HTBF7RDGVQ","metadata":{"order_id":"1001"},"invoice":null, …}} ``` | Header | Meaning | |---|---| | `Hatang-Access-Key` | Which account and environment the call is for (`ak_…`, or `ak_test_…` in development) | | `Hatang-Signature` | Lowercase hex HMAC-SHA256 of the raw body | | `Hatang-Signature-Algorithm` | Always `HMAC-SHA256` | | `Hatang-Event` | The event type, the same as `type` in the body | | `Hatang-Delivery-Id` | The same as `id` in the body; dedupe on it | | `Hatang-Environment` | `development` or `production` | | Body field | Meaning | |---|---| | `id` | The delivery id (`ipn_…`), the same on every retry | | `event_id` | The event that caused the call | | `type` | `payment.succeeded`, `payment.partially_paid`, `payment.overpaid`, `payment.expired`, `payment.canceled`, or an `invoice.*` event of this payment | | `environment` | `development` or `production` | | `created_at` | When the call was built | | `data` | The whole [payment object](https://hatang.dev/docs/guides/payments/create-payment.md#response), including its `invoice` summary | `data` is the payment as it stood when the call was built. For an `invoice.*` event it is still the payment, with the new state in `data.invoice`. ## Retries and the delivery log A call that does not get a `2xx` within 10 seconds is tried again after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12 hours: 8 attempts over about a day. After the last one the delivery is `exhausted`. Retries are signed with the secret key current at the time they are sent. `GET /payments/{id}/ipn-deliveries` lists the last 100 calls of a payment, newest first, with `status` (`pending`, `success`, `failed`, `exhausted`), `attempt`, `response_status`, `response_ms`, `last_error` and `next_attempt_at`. The payment page in the dashboard shows the same list. When your endpoint was down for longer than the retries, poll the affected payments. ## Poll the API Fetch a payment when you need its current state, for example when the payer comes back to your `return_url`, or in a periodic job that catches up on orders still unpaid. **cURL** ```bash curl https://api.hatang.dev/payments/pay_123 \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts const { data: check } = await hatang.payments.verify("pay_123", { order_id: "1001", amount: 150_000, environment: "production" }); if (check?.verified) await fulfil("1001"); ``` **Python** ```python p = hatang.payments.get("pay_123") if p["status"] in ("paid", "overpaid") and p["paid_amount"] >= 150000 and p["environment"] == "production": fulfil("1001") ``` **PHP** ```php $p = json_decode($client->get('/payments/pay_123')->getBody(), true); if (in_array($p['status'], ['paid', 'overpaid'], true) && $p['paid_amount'] >= 150000 && $p['environment'] === 'production') { fulfil('1001'); } ``` To catch up on many payments, list them with `GET /payments?status=paid,overpaid` and page with `after`. Polling counts toward the rate limit of 20 Payments requests per second. ## Next steps - [Verify IPN signatures](https://hatang.dev/docs/guides/payments/verify-ipn.md) in Node.js, PHP, Python and C#. - [Webhooks](https://hatang.dev/docs/guides/payments/webhooks.md): set up an endpoint and the event list. - [Go live](https://hatang.dev/docs/guides/payments/go-live.md). Source: https://hatang.dev/docs/guides/payments/payment-results --- # Quickstart Take your first payment in the `development` environment: link the development bank, create a payment, pretend the payer transferred the money, and receive the signed notification on your server. You do not need a real bank account. ## Before you start - A Ha Tang account with the **Payments** module switched on (Business settings → Modules). While it is off, every Payments request answers `403 product_disabled`. - A server that can receive an HTTPS `POST` from the internet, for step 6. On your laptop, use a tunnel such as ngrok or Cloudflare Tunnel. Every request on this page sends `Hatang-Environment: development`. Without that header, requests go to production. See [Environments and sandbox](https://hatang.dev/docs/guides/payments/environments.md). ## Step 1: Create an API key In the dashboard, open **API Keys** and create a key with the `payments:read` and `payments:write` scopes. Keep it on your server. ```bash export HATANG_API_KEY=ht_123... ``` For Node.js, install the SDK: ```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", environment: "development", // sent as the Hatang-Environment header }); ``` ## Step 2: Link the development bank Start the link, then confirm it with the code `000000`. The development bank always uses that code. **cURL** ```bash curl https://api.hatang.dev/payments/bank-connections \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Content-Type: application/json" \ -d '{ "provider": "simulator", "label": "Test account" }' curl https://api.hatang.dev/payments/bank-connections/bcn_123/confirm \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Content-Type: application/json" \ -d '{ "otp": "000000" }' ``` **Node.js** ```ts import { hatang } from "./lib/hatang"; const { data: conn, error } = await hatang.payments.bankConnections.create({ provider: "simulator", label: "Test account" }); if (error) throw new Error(`${error.name}: ${error.message}`); await hatang.payments.bankConnections.confirm(conn.id, "000000"); ``` **Python** ```python import os, requests API = "https://api.hatang.dev" HEADERS = {"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}", "Hatang-Environment": "development"} conn = requests.post(f"{API}/payments/bank-connections", headers=HEADERS, json={"provider": "simulator", "label": "Test account"}, timeout=10).json() requests.post(f"{API}/payments/bank-connections/{conn['id']}/confirm", headers=HEADERS, json={"otp": "000000"}, timeout=10).raise_for_status() ``` **PHP** ```php 'https://api.hatang.dev', 'headers' => [ 'Authorization' => 'Bearer ' . getenv('HATANG_API_KEY'), 'Hatang-Environment' => 'development', ], ]); $conn = json_decode($client->post('/payments/bank-connections', [ 'json' => ['provider' => 'simulator', 'label' => 'Test account'], ])->getBody(), true); $client->post("/payments/bank-connections/{$conn['id']}/confirm", ['json' => ['otp' => '000000']]); ``` The first call answers `201` with the connection in `pending_otp` and a `challenge`; the confirmation turns it `active`: ```json { "object": "bank_connection", "id": "bcn_01a0b90428fd7c499145a12c133abe41", "environment": "development", "provider": "simulator", "bank": { "bin": "970422", "name": "MBBank" }, "account_number": "SIM4821937506", "account_name": "TAI KHOAN GIA LAP", "account_type": "personal", "status": "active", "label": "Test account", "link_mode": "otp", "activated_at": "2026-09-19T09:30:02.114Z", "created_at": "2026-09-19T09:29:51.402Z" } ``` You can do the same in the dashboard: **Payments settings → Bank accounts → Add bank account**. ## Step 3: Create a payment Send an `Idempotency-Key` (your order id works well) so that a retried request returns the same payment instead of a second one. **cURL** ```bash curl https://api.hatang.dev/payments \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Idempotency-Key: order-1001" \ -H "Content-Type: application/json" \ -d '{ "amount": 150000, "description": "Order #1001", "ipn_url": "https://shop.example.com/hatang/ipn", "metadata": { "order_id": "1001" } }' ``` **Node.js** ```ts // createOrder uses the order id as the Idempotency-Key and stores it in metadata.order_id. const { data: payment, error } = await hatang.payments.createOrder({ order_id: "1001", amount: 150_000, description: "Order #1001", ipn_url: "https://shop.example.com/hatang/ipn", }); if (error) throw new Error(`${error.name}: ${error.message}`); console.log(payment.checkout_url); ``` **Python** ```python from hatang import Hatang # pip install hatang hatang = Hatang(environment="development") # sent as the Hatang-Environment header payment = hatang.payments.create({ "amount": 150000, "description": "Order #1001", "ipn_url": "https://shop.example.com/hatang/ipn", "metadata": {"order_id": "1001"}, }, idempotency_key="order-1001") print(payment["checkout_url"]) ``` **PHP** ```php $payment = json_decode($client->post('/payments', [ 'headers' => ['Idempotency-Key' => 'order-1001'], 'json' => [ 'amount' => 150000, 'description' => 'Order #1001', 'ipn_url' => 'https://shop.example.com/hatang/ipn', 'metadata' => ['order_id' => '1001'], ], ])->getBody(), true); echo $payment['checkout_url']; ``` The answer is `201` with the payment in `pending`, its `code`, a `qr` and a `checkout_url`. The full object is described in [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md#response). ## Step 4: Open the checkout page Open `checkout_url` in a browser. It shows the VietQR, the bank details and a countdown. In development it also has a button that simulates the transfer. ## Step 5: Simulate the transfer **cURL** ```bash curl https://api.hatang.dev/payments/simulate/transfer \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Content-Type: application/json" \ -d '{ "payment_id": "pay_123" }' ``` **Node.js** ```ts await hatang.payments.simulateTransfer({ payment_id: payment.id }); ``` **Python** ```python requests.post(f"{API}/payments/simulate/transfer", headers=HEADERS, json={"payment_id": payment["id"]}, timeout=10).raise_for_status() ``` **PHP** ```php $client->post('/payments/simulate/transfer', ['json' => ['payment_id' => $payment['id']]]); ``` The call answers `202`. Within a few seconds the payment becomes `paid`, the checkout page switches to its paid state, and your `ipn_url` receives a `payment.succeeded` call. ## Step 6: Handle the IPN Your endpoint must check the `Hatang-Signature` header against the raw body before it trusts anything, then answer `2xx` within 10 seconds. The secret key is in **Payments settings → Integration keys** (development keys start with `sk_test_`). ```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(); // the raw body, before any JSON parsing try { const ipn = await constructIpnEvent(raw, req.headers, process.env.HATANG_SECRET_KEY!); if (ipn.data.status === "paid" || ipn.data.status === "overpaid") { await markOrderPaid(ipn.data.metadata.order_id as string, ipn.data.paid_amount); } 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 } } ``` [Verify IPN signatures](https://hatang.dev/docs/guides/payments/verify-ipn.md) has the same handler in PHP, Python and C#. ## Step 7: Check what was delivered Every IPN call and its result is listed for the payment, newest first: ```bash curl https://api.hatang.dev/payments/pay_123/ipn-deliveries \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" ``` ```json { "object": "list", "data": [ { "object": "ipn_delivery", "id": "ipn_01a0b90429d87cf89e37fc2f66040ec5", "payment_id": "pay_01a0b904297873e8b3524a26fdca0ab5", "event_type": "payment.succeeded", "url": "https://shop.example.com/hatang/ipn", "status": "success", "attempt": 1, "response_status": 200, "response_ms": 84, "last_error": null, "next_attempt_at": null, "delivered_at": "2026-09-19T09:34:13.041Z", "created_at": "2026-09-19T09:34:12.952Z" } ] } ``` The payment page in the dashboard shows the same list. ## What can go wrong | Error | Why | Fix | |---|---|---| | `422 no_bank_connection` | No active bank account in this environment | Do step 2, or check that you sent `Hatang-Environment: development` | | `422 validation_error` with "Several bank accounts are linked; pass connection_id" | More than one active account | Send `connection_id` when you create the payment | | `422 validation_error` on `ipn_url` | The URL is internal (`localhost`, `10.x`, `192.168.x`…) or not `https` | Use a public HTTPS URL, such as a tunnel | | `401 restricted_api_key` | The key lacks a Payments scope | Create a key with `payments:read` and `payments:write` | | `403 environment_mismatch` | You simulated a transfer outside development | Send `Hatang-Environment: development` | | No IPN arrives | Your endpoint did not answer `2xx` | Look at `last_error` in step 7; Ha Tang retries for about a day | ## Next steps - [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md): every field, status and edge case. - [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md): IPN, webhooks and polling compared. - [JavaScript SDK](https://hatang.dev/docs/guides/payments/sdk-javascript.md): the whole flow in a few calls. - [Go live](https://hatang.dev/docs/guides/payments/go-live.md) when you are ready for real money. Source: https://hatang.dev/docs/guides/payments/quickstart --- # 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: , 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 ` | Not paid or overpaid yet | | `paid , expected ` | Paid, but less than the amount you expect | | `belongs to another order` | `metadata.order_id` is not the `order_id` you passed | | `is a 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(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 --- # Receive transfers without orders Use this flow when your system already has order codes and you only need to know when money for them arrives, for example a static QR code at a shop counter. You do not create a payment per order. Ha Tang reports every transfer into the account as `transaction.received`, with your order code if it found one in the description. ## Before you start - An active [bank connection](https://hatang.dev/docs/guides/payments/bank-connections.md). - A [webhook endpoint](https://hatang.dev/docs/guides/payments/webhooks.md) subscribed to `transaction.received`. This flow has no IPN, because there is no payment to attach an `ipn_url` to. ## 1. Tell Ha Tang what your order codes look like {#1-tell-hatang-what-your-order-codes-look-like} Set `payment_code_pattern` on the bank account: a prefix and a length range. Ha Tang then looks for it in every transfer description. **cURL** ```bash curl -X PATCH https://api.hatang.dev/payments/bank-connections/bcn_123 \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "config": { "payment_code_pattern": { "prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits" } } }' ``` **Node.js** ```ts await hatang.payments.bankConnections.update("bcn_123", { config: { payment_code_pattern: { prefix: "DH", min_length: 4, max_length: 8, charset: "digits" } }, }); ``` **Python** ```python requests.patch("https://api.hatang.dev/payments/bank-connections/bcn_123", headers=HEADERS, json={ "config": {"payment_code_pattern": {"prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits"}}, }, timeout=10).raise_for_status() ``` **PHP** ```php $client->patch('/payments/bank-connections/bcn_123', ['json' => [ 'config' => ['payment_code_pattern' => ['prefix' => 'DH', 'min_length' => 4, 'max_length' => 8, 'charset' => 'digits']], ]]); ``` ### How the code is found 1. The description is normalized: accents removed, `đ` becomes `D`, everything upper-cased, and every character that is not a letter or a digit dropped. `ck dh 2025-0919 cam on` becomes `CKDH20250919CAMON`. 2. Ha Tang looks for the prefix followed by `min_length` to `max_length` characters of the charset, and takes the first match, as long as possible. With the pattern above, `DH20250919` is found in `ck dh 2025-0919 cam on`. > **Caution** > > Spaces are removed before the search, so with `charset: "alnum"` the words after your code run into it: `DH1001 cam on` becomes `DH1001CAMON` and the match is `DH1001CAMO`. Prefer `digits`, or make every code exactly `max_length` long. ## 2. Show a VietQR with your code `GET /payments/qr` builds a VietQR for any amount and description. Nothing is stored. **cURL** ```bash # JSON with the EMVCo payload curl "https://api.hatang.dev/payments/qr?amount=99000&content=DH20250919" \ -H "Authorization: Bearer $HATANG_API_KEY" # An SVG image curl "https://api.hatang.dev/payments/qr?amount=99000&content=DH20250919&format=svg" \ -H "Authorization: Bearer $HATANG_API_KEY" -o qr.svg ``` **Node.js** ```ts const { data: qr } = await hatang.payments.qr({ amount: 99_000, content: "DH20250919" }); // Render qr.payload with any QR library. ``` **Python** ```python qr = requests.get("https://api.hatang.dev/payments/qr", headers=HEADERS, params={"amount": 99000, "content": "DH20250919"}, timeout=10).json() ``` **PHP** ```php $qr = json_decode($client->get('/payments/qr', ['query' => ['amount' => 99000, 'content' => 'DH20250919']])->getBody(), true); ``` ```json { "object": "vietqr", "payload": "00020101021238530010A0000007270123000697042201099104526580208QRIBFTTA53037045405990005802VN62140810DH202509196304FCC9", "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41", "bank": { "bin": "970422", "account_number": "910452658", "account_name": "NGUYEN VAN A" }, "amount": 99000, "content": "DH20250919" } ``` | Parameter | Meaning | |---|---| | `amount` | VND. Leave it out for a QR where the payer types the amount (a counter sticker) | | `content` | The description to prefill, up to 50 characters | | `connection_id` | Which account; required only when several are linked | | `format` | `json` (default) or `svg` | | `logo` | With `format=svg`: `0` leaves the Ha Tang mark out of the image | The QR only prefills the description. A payer who transfers by hand must type the code themselves, so print it next to the QR. ## 3. Receive `transaction.received` ```json { "type": "transaction.received", "created_at": "2026-09-19T09:34:12.946Z", "data": { "object": "transaction", "id": "txn_01a0b90429dc79c7a5689c7a0c485a01", "environment": "production", "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41", "direction": "in", "amount": 99000, "currency": "VND", "description": "DH20250919 chuyen tien", "code": "DH20250919", "merchant_code": "DH20250919", "reference": "FT1789810452930810", "counterparty": { "account": "0987654321", "name": "NGUYEN VAN KHACH", "bank": "970436" }, "booked_at": "2026-09-19T09:34:12.930Z", "source": "push", "payment_id": null, "match_status": "unmatched", "invoice_exempt": null, "created_at": "2026-09-19T09:34:12.946Z" } } ``` | Field | Meaning | |---|---| | `merchant_code` | Your order code as found in the description; `null` when none was found | | `code` | The code stored on the transaction: a Ha Tang payment code when the transfer paid a payment, otherwise your order code | | `reference` | The bank's reference number | | `source` | How Ha Tang learned about it: `push` (the bank notified it), `reconcile` (read from the account history), `import` (a statement) or `simulator` (development) | | `payment_id`, `match_status` | Set when the transfer paid one of your payments (`matched`); `unmatched` otherwise | | `counterparty.bank` | The payer's bank BIN, when the bank reports it | Your handler should: 1. Verify the webhook signature (see [Webhooks](https://hatang.dev/docs/guides/payments/webhooks.md)). 2. Find the order by `merchant_code`, and compare `amount` with what it should be. 3. Store `data.id`. The same transaction is never reported twice, but your endpoint may receive the same delivery again after a timeout. 4. Check `environment` and `direction`. ### Edge cases | Situation | What you get | |---|---| | The payer forgot the code | `merchant_code: null`. Match by amount and `counterparty.name`, or ask the payer | | The payer sent the wrong amount | The amount as transferred; deciding is up to you | | The payer paid twice | Two transactions with different `id` and `reference` | | The bank pushed the same transfer twice | One transaction (same reference and amount on the account) | | The transfer also carries a payment code | It settles that payment (`payment.succeeded`, etc.) and `transaction.received` has `payment_id` set. A transfer is never counted twice | ## Filter what is reported Per bank account, under `transaction_events` in the config or in the dashboard: | Setting | Default | Effect | |---|---|---| | `direction` | `in` | `all` also reports money going out | | `require_code` | `false` | `true` reports only transfers where a code was found or a payment matched | | `min_amount` | `0` | Transfers below this amount are not reported | ## List and look up transactions Every transfer is stored, whether or not it was reported. **cURL** ```bash curl "https://api.hatang.dev/payments/transactions?direction=in&match_status=unmatched&limit=50" \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts const { data: page } = await hatang.payments.transactions.list({ direction: "in", match_status: "unmatched", limit: 50 }); if (page.has_more) await hatang.payments.transactions.list({ after: page.next_cursor! }); ``` **Python** ```python page = requests.get("https://api.hatang.dev/payments/transactions", headers=HEADERS, params={"direction": "in", "match_status": "unmatched", "limit": 50}, timeout=10).json() ``` **PHP** ```php $page = json_decode($client->get('/payments/transactions', ['query' => [ 'direction' => 'in', 'match_status' => 'unmatched', 'limit' => 50, ]])->getBody(), true); ``` | Parameter | Meaning | |---|---| | `limit` | 1 to 100, default 20 | | `after` | The `next_cursor` of the previous page | | `connection_id` | One bank account | | `direction` | `in` or `out` | | `match_status` | `matched`, `unmatched` or `ignored` | | `payment_id` | The transactions that settled this payment | The answer is `{ "object": "list", "data": [...], "has_more": true, "next_cursor": "txn_..." }`, newest first. `GET /payments/transactions/{id}` returns one transaction. ## Next steps - [Issue an e-invoice](https://hatang.dev/docs/guides/payments/einvoices.md#invoices-without-a-payment) for a transfer with `transaction_id`. - [Import a statement](https://hatang.dev/docs/guides/payments/bank-connections.md#statement-import) to recover transfers the bank missed. - [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md) when you want a checkout page and expiry. Source: https://hatang.dev/docs/guides/payments/transactions --- # Verify IPN signatures Every IPN call is signed, so your endpoint can tell a real call from Ha Tang apart from a forged one. This page explains the signature, gives a working handler in four languages, a test vector to check your code against, and the mistakes that most often break verification. ## The keys Each business has an **access key** and a **secret key** per environment, created with the business. Find them in the dashboard under **Payments settings → Integration keys**. Development keys start with `ak_test_` and `sk_test_`. - `Hatang-Access-Key` tells you which account and environment the call is for. - `Hatang-Signature` is the **lowercase hex HMAC-SHA256 of the raw request body**, keyed with the secret key (its UTF-8 bytes, as shown). To verify: compute the same HMAC over the exact bytes you received, and compare it with the header in constant time. Keep the secret key on your server only. ## Handler examples **Node.js** ```js import express from "express"; import { createHmac, timingSafeEqual } from "node:crypto"; const SECRET = process.env.HATANG_SECRET_KEY; // sk_… function verifyIpn(rawBody, signature, secret) { const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); const got = String(signature ?? "").trim().toLowerCase(); return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected)); } const app = express(); // The raw bytes, not parsed JSON: re-serializing changes the body and breaks the signature. app.post("/hatang/ipn", express.raw({ type: "application/json" }), async (req, res) => { if (!verifyIpn(req.body, req.get("Hatang-Signature"), SECRET)) return res.status(401).end(); const ipn = JSON.parse(req.body.toString("utf8")); if (await alreadyHandled(ipn.id)) return res.status(200).end(); await queue.add("hatang-ipn", ipn); // update the order in the background res.status(200).end(); }); ``` With the SDK: `constructIpnEvent(rawBody, headers, secretKey)` verifies and returns the parsed call (or throws `HatangSignatureError`), and `verifyIpn(rawBody, signature, secretKey)` returns a boolean. See [JavaScript SDK](https://hatang.dev/docs/guides/payments/sdk-javascript.md). **Python** ```python import os from flask import Flask, request, abort from hatang import HatangSignatureError, construct_ipn_event SECRET = os.environ["HATANG_SECRET_KEY"] # sk_… app = Flask(__name__) @app.post("/hatang/ipn") def hatang_ipn(): try: # bytes, exactly as received; checks Hatang-Signature ipn = construct_ipn_event(request.get_data(), request.headers, SECRET) except HatangSignatureError: abort(401) # dedupe on ipn["id"], then update the order from ipn["data"]["status"] return "", 200 ``` **PHP** ```php { using var ms = new MemoryStream(); await req.Body.CopyToAsync(ms); var raw = ms.ToArray(); var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("HATANG_SECRET_KEY")!); var expected = Convert.ToHexString(HMACSHA256.HashData(secret, raw)).ToLowerInvariant(); var got = req.Headers["Hatang-Signature"].ToString().Trim().ToLowerInvariant(); if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(got))) return Results.Unauthorized(); // dedupe on the "id" field, then update the order return Results.Ok(); }); ``` ## Test vector With the secret key `sk_test_example` and the body `{"id":"ipn_test","type":"payment.succeeded"}` (exactly those bytes), the signature is: ``` 35c86d268e66be5f34c25d834de9da3ba1f0c6d26fb735266440886df6b5d955 ``` Check it from a shell: ```bash printf '%s' '{"id":"ipn_test","type":"payment.succeeded"}' | openssl dgst -sha256 -hmac sk_test_example ``` And send a signed test call to your local handler: ```bash BODY='{"id":"ipn_test","type":"payment.succeeded"}' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac sk_test_example | awk '{print $NF}') curl -i http://localhost:3000/hatang/ipn \ -H "Content-Type: application/json" \ -H "Hatang-Signature: $SIG" \ --data-raw "$BODY" ``` With `HATANG_SECRET_KEY=sk_test_example` your handler should answer `200`; change one character of the body and it should answer `401`. For an end-to-end test, create a payment in development and [simulate the transfer](https://hatang.dev/docs/guides/payments/environments.md#simulate-a-transfer). ## Common mistakes - **Parsing the JSON and serializing it again before verifying.** Spacing and key order change, so the HMAC does not match. Verify the raw bytes. In Express, use `express.raw()` on this route; in Next.js, read `await req.text()`. - **Using the other environment's secret key.** Development and production have different keys. `Hatang-Environment` and the `sk_test_` prefix tell you which one applies. - **Comparing with `==`.** Use a constant-time comparison. - **A proxy that changes the body.** Some gateways re-encode JSON or change the character set. Verify on the body exactly as Ha Tang sent it. - **Rotating the secret key.** After **Create a new secret key** in the dashboard, calls are signed with the new key immediately. Retries of older calls are signed with the key current when they are sent, so switch the key on your server right after rotating. ## Next steps - [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md): what to do once the call is verified. - [Webhooks](https://hatang.dev/docs/guides/payments/webhooks.md): the other signature scheme. - [Go live](https://hatang.dev/docs/guides/payments/go-live.md). Source: https://hatang.dev/docs/guides/payments/verify-ipn --- # 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](https://hatang.dev/docs/guides/getting-started/webhooks.md). ## 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** ```bash 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" }' ``` **Node.js** ```ts 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. ``` **Python** ```python 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() ``` **PHP** ```php $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](https://www.standardwebhooks.com/) / 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** ```ts 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)`. **Python** ```python 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** ```php 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,` in `svix-signature` (space-separated). Reject timestamps more than 5 minutes away from now. > **Tip: 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": { ... } }` | | 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 ```json { "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](https://hatang.dev/docs/guides/payments/payment-results.md): webhooks compared with IPN and polling. - [Receive transfers without orders](https://hatang.dev/docs/guides/payments/transactions.md): `transaction.received`. - [Webhooks reference](https://hatang.dev/docs/reference/webhooks.md). Source: https://hatang.dev/docs/guides/payments/webhooks --- # Issue Vietnamese e-invoices (hóa đơn điện tử) through an API Ha Tang issues e-invoices under your tax code through your own account at your e-invoice provider (see the full guide for the supported providers). Connect the account once in **Payments settings → Invoice providers**, then either add an `invoice` block to a payment, so the invoice is issued once the order is paid, or create one on its own. **With the payment** (issued when it is paid) ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(); // HATANG_API_KEY with payments:write const { data: payment, error } = await hatang.payments.createOrder({ order_id: "SO-1002", amount: 275_000, ipn_url: "https://shop.example.com/api/hatang/ipn", invoice: { mode: "publish", buyer: { name: "Nguyen Van Khach", tax_code: "0109876543", email: "accounting@example.com" }, items: [{ name: "September service plan", quantity: 1, unit_price: 250_000, vat_rate: 10 }], }, }); ``` The IPN and `payments.get` carry the invoice's status and number (`payment.invoice`). **On its own**, for example for a bank transfer received without an order ```ts const { data: invoice, error } = await hatang.einvoices.create({ mode: "publish", transaction_id: "txn_123", buyer: { name: "Nguyen Van Khach" }, items: [{ name: "Coffee beans 1 kg", quantity: 1, unit_price: 91_667, vat_rate: 8 }], }); ``` In `development`, connect the provider's demo account: the invoices are real sandbox invoices but never reach the tax office. ## Go further - Connecting the provider, the lifecycle, drafts, failures and replacements: [E-invoices](https://hatang.dev/docs/guides/payments/einvoices.md) - Payments the invoice can follow: [Accept VietQR bank transfers](https://hatang.dev/docs/guides/recipes/vietqr-payments.md) Source: https://hatang.dev/docs/guides/recipes/einvoice-api --- # Add live chat to a Next.js or React site The Ha Tang widget is a chat bubble your team answers from the Ha Tang inbox, next to Zalo, Messenger, Instagram, TikTok Shop, WhatsApp and email, with an optional AI assistant that answers from your own content. Get your site id under **Support → Channels → your website → Install** (it starts with `site_`). **Next.js or React** ```bash npm install @hatang/chat-react ``` ```tsx title="app/layout.tsx" import { HatangChat } from "@hatang/chat-react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` Open it from your own button with `useHatang()`: `const chat = useHatang(); chat.open();`. Pass `user={{ userId, email, name, hash }}` to attach the signed-in user, with `hash` computed on your server. **Any other site** ```html ``` ## Go further - The component, the hook and every option: [React and Next.js](https://hatang.dev/docs/guides/sdks/react.md) - Sign user identities so nobody can pose as a customer: [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md) - The widget's JavaScript API: [JavaScript API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md) - Answer automatically: [AI assistant](https://hatang.dev/docs/guides/support/ai-assistant.md) - Mobile apps: [Flutter](https://hatang.dev/docs/guides/sdks/flutter.md), [React Native](https://hatang.dev/docs/guides/sdks/react-native.md), [iOS](https://hatang.dev/docs/guides/sdks/ios.md), [Android](https://hatang.dev/docs/guides/sdks/android.md) Source: https://hatang.dev/docs/guides/recipes/live-chat-nextjs --- # Send transactional email from Node.js and Next.js Install `@hatang/sdk`, verify your sending domain once, then call `emails.send` from server code. The API is Resend-compatible, and SMTP works too. ```bash npm install @hatang/sdk ``` ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(); // reads HATANG_API_KEY const { data, error } = await hatang.emails.send( { from: "Acme ", // a domain verified under Email → Domains to: "lan@example.net", subject: "Order #1024 is confirmed", html: "

Hi Lan, your order #1024 is confirmed.

", text: "Hi Lan, your order #1024 is confirmed.", }, { idempotencyKey: "order-1024-confirmation" }, // a retry never sends twice ); if (error) throw new Error(`${error.name}: ${error.message}`); console.log(data.id); ``` In Next.js, call it from a Server Action or a Route Handler, never from a client component: the API key is a secret. ## Checklist 1. Create an API key with the `email:send` scope under **Settings → API Keys** and put it in `HATANG_API_KEY`. 2. Add and verify your domain: publish the DKIM, SPF and return-path records it lists. See [Domains](https://hatang.dev/docs/guides/email/domains.md). 3. Send. Follow delivery with `emails.get(id)` or the `email.delivered` and `email.bounced` [webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md). ## Go further - Every field, attachments, scheduling and batches: [Send email](https://hatang.dev/docs/guides/email/sending.md) - Reusable templates with variables: [Templates](https://hatang.dev/docs/guides/email/templates.md) - React Email components: pass `react: ` instead of `html` ([Node.js SDK](https://hatang.dev/docs/guides/sdks/node.md)) - Coming from Resend, SendGrid or Postmark: [Migrate from Resend](https://hatang.dev/docs/guides/email/migrate-from-resend.md) Source: https://hatang.dev/docs/guides/recipes/send-email-nodejs --- # Accept VietQR bank transfers in your app Ha Tang watches your own bank account. You create a payment with an amount and a transfer code, the buyer scans the VietQR code in their banking app, and Ha Tang matches the incoming transfer and tells your server. No card processor, no money held by a third party. ```bash npm install @hatang/sdk ``` **1. Create the payment and send the buyer to checkout** ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(); // HATANG_API_KEY; HATANG_ENVIRONMENT=development while you build const { data: payment, error } = await hatang.payments.createOrder({ order_id: "SO-1001", // also the idempotency key amount: 150_000, description: "Order #1001", return_url: "https://shop.example.com/orders/SO-1001", ipn_url: "https://shop.example.com/api/hatang/ipn", }); if (error) throw new Error(`${error.name}: ${error.message}`); redirect(payment.checkout_url); // or draw payment.qr.image_url yourself ``` **2. Receive the IPN** (`app/api/hatang/ipn/route.ts`) ```ts import { constructIpnEvent, HatangSignatureError } from "@hatang/sdk"; export async function POST(req: Request) { const raw = await req.text(); // the raw body, before any JSON parsing try { const ipn = await constructIpnEvent(raw, req.headers, process.env.HATANG_SECRET_KEY!); if (ipn.data.status === "paid" || ipn.data.status === "overpaid") { await markOrderPaid(ipn.data.metadata.order_id as string); // idempotently: an IPN can repeat } return new Response(null, { status: 200 }); } catch (err) { if (err instanceof HatangSignatureError) return new Response(null, { status: 401 }); throw err; // a 5xx makes Ha Tang retry } } ``` **3. Confirm on the return page** ```ts const { data: check } = await hatang.payments.verify(paymentId, { order_id: "SO-1001", amount: 150_000 }); if (check?.verified) await fulfil("SO-1001"); ``` In the `development` environment no money moves: the checkout page has a button that simulates the transfer, or call `hatang.payments.simulateTransfer({ payment_id })`. ## Go further - The whole flow with every option: [Payments with the JavaScript SDK](https://hatang.dev/docs/guides/payments/sdk-javascript.md) - Link your bank account: [Bank connections](https://hatang.dev/docs/guides/payments/bank-connections.md) - Issue an e-invoice automatically once paid: [E-invoices](https://hatang.dev/docs/guides/payments/einvoices.md) - Create and settle test payments from your coding agent: [MCP server](https://hatang.dev/docs/guides/sdks/mcp.md) Source: https://hatang.dev/docs/guides/recipes/vietqr-payments --- # Send Zalo ZNS messages from your backend ZNS (Zalo Notification Service) sends an approved template to a phone number through your Zalo Official Account, even if the customer never messaged you. Ha Tang connects the OA once and gives you one API call; replies land in the same inbox as your other channels. **Before you start:** connect the Zalo OA under **Support → Channels**, put it on a Zalo tier that includes ZNS, and get your templates approved at oa.zalo.me. Use an API key with `crm:write`. ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(); // HATANG_API_KEY const { data, error } = await hatang.chat.zns.send({ account_id: "cha_123", // the connected OA template_id: "231234", // an approved template phone: "0912345678", // 84912345678 and +84 912 345 678 work too template_data: { customer_name: "Lan", order_code: "DH1024", amount: "350.000đ" }, }); if (error) throw new Error(error.message); if (data.status === "failed") console.error("Zalo refused:", data.error); ``` The same call over HTTP is `POST https://api.hatang.dev/chat/zns`. List an OA's templates and its daily quota with `hatang.chat.zns.templates("cha_123")`. Zalo charges for every message. To send to many customers at once, or from a workflow when an order is paid, see the full guide. ## Go further - Templates, bulk sends, workflows and errors: [Zalo ZNS](https://hatang.dev/docs/guides/support/zns.md) - Everything else the support API does: [Support REST API](https://hatang.dev/docs/guides/support/rest-api.md) Source: https://hatang.dev/docs/guides/recipes/zalo-zns --- # Android SDK `dev.hatang:chat` `dev.hatang:chat` adds a native support chat to your Android app. It talks the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md) to the Ha Tang gateway directly (no web view). The UI is written in Jetpack Compose, and apps built with Views and XML open it with one line, `HatangChat.open(context)`. The SDK does not depend on Firebase: your app gets the FCM token and passes it to the SDK. Customers who write from your app land in the same inbox as visitors from your website, Zalo, Messenger, Instagram and TikTok Shop. > **Info: Version** > > `dev.hatang:chat` **0.4.1** · protocol v1 · Android 8.0 (API 26)+ · Kotlin 1.9+ · Jetpack Compose (BOM 2024.12+) · [Maven Central](https://central.sonatype.com/artifact/dev.hatang/chat) · [Changelog](https://hatang.dev/docs/changelog) · [API reference (Dokka)](https://hatang.dev/docs/reference/android/index.html) > **Note** > > Version 0.3 includes the chat screen, the visitor's conversation list, a floating launcher, signed identity, photo and file attachments, product, order and payment cards, push notifications through your own Firebase project and session-restoring deep links. The API may still change before 1.0. ## Installation {#installation} ### Get an app key from the dashboard The SDK needs two values: the **Site ID** and an **app key** (`hak_…`). The app key replaces the browser's Origin check: the app sends it when it starts, so the gateway knows which app a session comes from. 1. In the dashboard, open **Support → Channels → Add channel → Mobile app**. 2. Click **Create app key**, give it a name (for example "Moc Huong Android") and pick the Android platform. The package name is optional and only a reminder. 3. The key is shown once. Pass it to `HatangConfig(appKey = …)`. App keys are public in the same way the site ID is (they ship inside your APK), but you can revoke them. To rotate a key safely, create a new one, ship an update that uses it, then revoke the old key. Sessions that use a revoked key are cut off within a minute. ### Add the dependency The library is on Maven Central, which new Android projects already list in `settings.gradle.kts`. ```kotlin title="app/build.gradle.kts" dependencies { implementation("dev.hatang:chat:0.4.1") } ``` There are two artifacts: | Artifact | Contains | |---|---| | `dev.hatang:chat` | The `HatangChat` facade, the Compose UI, secure storage, lifecycle handling and attachments. Use this one. | | `dev.hatang:chat-core` | Pure Kotlin/JVM: protocol types, REST client, WebSocket and the `ChatEngine` (as a `StateFlow`). Pulled in by `chat`; use it alone only to build your own UI. | The SDK uses common libraries (OkHttp, kotlinx.serialization, coroutines, Compose Material 3, Coil, DataStore, Tink, and the ZXing core encoder for QR codes). If your app already depends on them, Gradle picks the higher version. ### Attachments and permissions The composer's attach button offers the photo picker (`PickVisualMedia`, images only) and files (`OpenDocument`). Photos in HEIC/HEIF are converted to JPEG and scaled down to 2048 px before upload; files can be up to 10 MB. Both pickers are system UIs and need no extra permissions. The library's manifest adds only `INTERNET` and `ACCESS_NETWORK_STATE`, and declares its own `HatangChatActivity` (with `adjustResize`, so the composer stays above the keyboard). ## Initialization {#initialization} Call `HatangChat.configure` once in your `Application.onCreate`. With `autoConnect` (the default), the SDK bootstraps the visitor, stores the token and opens the socket right away. ```kotlin title="App.kt" import android.app.Application import dev.hatang.chat.HatangChat import dev.hatang.chat.HatangConfig import dev.hatang.chat.core.protocol.HatangLocale import dev.hatang.chat.platform.logcatLogger class App : Application() { override fun onCreate() { super.onCreate() HatangChat.configure( this, HatangConfig( siteId = "site_…", appKey = "hak_…", locale = HatangLocale.VI, logger = logcatLogger(), // while integrating; the default is HatangLogger.SILENT ), ) } } ``` | `HatangConfig` field | Default | Meaning | |---|---|---| | `siteId`, `appKey` | required | From the website's **Apps** tab. | | `baseUrl` | `HatangChat.DEFAULT_BASE_URL` (`https://chat.hatang.dev`) | The gateway. Change it only for a local gateway (see [Troubleshooting](#troubleshooting)). | | `locale` | `null` | `HatangLocale.VI` or `HatangLocale.EN`. Without it: the visitor's stored choice, then the site's default, then the device. | | `themeMode`, `theme`, `darkTheme` | `SITE`, `null`, `null` | See [Theming](#theming). | | `strings` | `null` | Wording overrides per locale; see [Languages](#languages). | | `autoConnect` | `true` | `false` to call `HatangChat.connect()` yourself later (for example after a consent screen). | | `openOnProactiveMessage` | `true` | Open the chat when the team starts a conversation that asks to open on the visitor's device. | | `logger` | `HatangLogger.SILENT` | `logcatLogger()` writes socket frames and HTTP calls to Logcat. | | `appInfo` | read from the package manager and `Build` | Overrides the app and device details sent to the gateway. | | `commerce` | `HatangCommerce.STANDARD` | How "Buy" and "Pay now" on chat cards behave; see [Sales cards and in-app purchase](#commerce). | Calling `configure` again replaces the previous engine. Other members of the facade: | Member | Meaning | |---|---| | `HatangChat.isConfigured` | `configure` has run. | | `HatangChat.connect()` / `disconnect()` | Suspend functions: bootstrap and connect, or close the socket while staying configured. | | `HatangChat.dispose()` | Release the engine; call `configure` again to use the SDK. | | `HatangChat.state` | `StateFlow` with everything the UI renders (`connection`, `unread`, `availability`, `locale`…). | | `HatangChat.unreadCount` | `StateFlow` of messages received while the chat was not on screen. | | `HatangChat.session` | The current visitor id, conversation id, locale, and identity details. | | `HatangChat.config`, `HatangChat.engine` | The configuration and the underlying `ChatEngine`; both throw before `configure`. | The SDK follows the process lifecycle on its own: when the app moves to the background it tells the gateway (`app.state`), so new replies go out as push notifications instead of over the socket. ## Identify visitors {#identify-visitors} When the user signs in, call `identify` with the id from your own system. `hash` is the HMAC-SHA256 (hex) of `userId` with the site's **identity secret**. Compute it **on your server** and return it to the app; never put the secret in the app. A signed identity is merged with the same person's earlier sessions (on the web too) and shows as verified in the inbox; without a signature the details are stored as self-reported. See [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md). ```kotlin import dev.hatang.chat.core.session.HatangUser lifecycleScope.launch { // Your backend returns { userId, hash } after sign-in HatangChat.identify( HatangUser( userId = me.id, email = me.email, name = me.name, phone = me.phone, hash = me.hatangHash, data = mapOf("plan" to "pro", "orders" to 12), ), ) } ``` `identify` throws `HatangException` (with `code`, `message` and `status`) when the gateway refuses the call. Computing `hash` on your backend takes one line: ```js title="Node.js" const hash = require("node:crypto").createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(userId).digest("hex"); ``` ```php title="PHP" $hash = hash_hmac('sha256', $userId, getenv('HATANG_IDENTITY_SECRET')); ``` When the identity already belongs to another visitor (the user chatted on another device, for example), the gateway merges the sessions: the SDK receives a new token, reloads the conversation and emits `HatangSessionChanged(reason = IDENTITY_MERGED)`. You do not need to do anything else. ### Sign-out ```kotlin lifecycleScope.launch { HatangChat.clearPushToken() // optional: stop pushes to this device HatangChat.reset() } ``` `reset()` revokes the old token and starts a fresh visitor, so the next person on the same device does not see the previous user's conversations. ## Open and embed the chat {#open-and-embed} ### Open the chat screen From anywhere with a `Context`, including View-based apps: ```kotlin HatangChat.open(context) // the active conversation (or a new one) HatangChat.open(context, conversationId = "cnv_…") // a specific conversation HatangChat.openInbox(context) // the visitor's conversation list HatangChat.close() ``` `open` starts the SDK's own `HatangChatActivity`. `HatangChat.openIntent(context, conversationId)` returns the same `Intent`, for example to put in a `PendingIntent`. `HatangChat.isOpen` tells you whether a chat screen is showing. ### Floating launcher `HatangChatLauncher` is a Compose chat button with an unread badge. It uses the site's brand color and the icon, image and label configured under **Launcher button** in the site settings, and hides itself while the team is away when the site asks for that. It renders nothing before `configure`. ```kotlin Box(Modifier.fillMaxSize()) { HomeContent() HatangChatLauncher( modifier = Modifier .align(Alignment.BottomEnd) .padding(16.dp), ) } ``` | Parameter | Default | Meaning | |---|---|---| | `modifier` | `Modifier` | Position and padding. | | `onClick` | `HatangChat.open(context)` | What a tap does. | | `showBadge` | `true` | Show the unread count. | | `engine` | `HatangChat.engine` | Engine override (tests, several sites). | | `icon` | `null` | A composable that replaces the site's icon. | ### Embed in your own navigation The screens are ordinary composables, so you can place them in your own navigation graph or tab: | Composable | Parameters | |---|---| | `HatangChatScreen` | `modifier`, `conversationId` (default: the active one), `onClose` (shows a close button), `title` (default: the site name), `engine`, `themeMode`, `theme`, `darkTheme`, `strings` | | `HatangConversationView` | The conversation and composer without a top bar, for a tab of your own: `modifier`, `engine`, `theme`, `darkTheme`, `strings` | | `HatangChatInbox` | `modifier`, `onOpenConversation`, `onNewConversation`, `onClose`, `showTopBar`, `engine` | ```kotlin NavHost(navController, startDestination = "home") { composable("home") { HomeScreen() } composable("support") { HatangChatScreen(onClose = { navController.popBackStack() }) } composable("support/history") { val context = LocalContext.current HatangChatInbox( onOpenConversation = { conv -> HatangChat.open(context, conv.id) }, onNewConversation = { HatangChat.open(context) }, ) } } ``` `HatangChatScreen` draws edge to edge and pads itself for the status bar, navigation bar and keyboard. `HatangChat.showMessage("Need help choosing a size?", senderName = "Moc Huong")` adds a local assistant bubble that is never sent to the gateway, for example a greeting that depends on the screen. ### Sales cards and in-app purchase {#commerce} Agents send product cards, order cards, payment requests, quick replies and rating requests from the inbox; the SDK draws them under the message. By default, **Buy** on a product card writes "I'd like to buy …" into the composer, and a payment card shows a VietQR code (drawn by the SDK from the payment data), the transfer note and a **Pay now** button that opens the web checkout. - **Physical goods and real-world services** (tea, clothing, delivery, bookings…): keep the default. Google Play's payments policy does not allow Play Billing for these, and Play Billing cannot charge an arbitrary order total anyway. - **Digital goods and features used inside your app** (memberships, coins, in-app courses…): these must be sold through Google Play Billing. Hand the purchase to your billing code and turn off external checkout: ```kotlin HatangChat.configure( this, HatangConfig( siteId = "site_…", appKey = "hak_…", commerce = HatangCommerce( onBuyProduct = { context, product -> // The Google Play product ID entered for this product in the dashboard; // null: not an in-app product, keep the default behavior product.googlePlayProductId?.let { id -> billing.launchPurchase(context, id); true } ?: false }, showExternalCheckout = false, // no QR code, no transfer note, no web checkout ), ), ) ``` | `HatangCommerce` field | Meaning | |---|---| | `onBuyProduct(context, product)` | **Buy** on a product card. Return `true` when you handled it; `false` falls back to prefilling the composer. | | `onPay(context, payment)` | **Pay now** on a payment card. Return `true` when you handled it; `false` falls back to the web checkout. | | `showExternalCheckout` | Default `true`. `false` hides the VietQR code, the transfer note and the web checkout. | Enter the store product IDs in the dashboard under **Products** → open a product → **Prices on the App Store and Google Play**. Product cards then carry `googlePlayProductId` and `appStoreProductId`. With `showExternalCheckout = false` and no `onPay`, a payment card shows only the amount and a note that the team will explain how to pay. > The whole flow, including store setup, reporting renewals and restores, importing existing subscribers and testing, is in [In-app purchases and subscriptions](https://hatang.dev/docs/guides/support/in-app-purchases.md). **Subscriptions.** A customer in your Android app sees the Google Play price you set for the product; subscription cards also carry `basePlanId`, `offerId`, `billingPeriod` (`P1M`, `P1Y`…) and `trialDays`. So that Google Play's real-time notifications reach this customer in Hatang, pass their account id when launching the billing flow, then tell the team: ```kotlin val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(details)) .setObfuscatedAccountId(HatangChat.purchaseToken(currentUser.id)) // the id you sign in with (identify) .build() // after onPurchasesUpdated succeeds: HatangChat.reportPurchase(storeProductId = "pro", purchaseToken = purchase.purchaseToken, productType = "subs") // "inapp" for one-time products ``` Hatang reads the purchase with your Play service account before recording it on the customer (a made-up token is refused) and **acknowledges** it, so you don't have to (Play refunds purchases left unacknowledged for three days). For one-time products pass `productType = "inapp"` and `consumable = true` or `false`; consume consumables in the app as usual. Purchases made before the customer signs in move to them when you call `identify`. Then connect the app under **Channels → your app → Subscriptions from the App Store and Google Play**: the customer's plan, renewals, cancellations and refunds show on their profile and the AI assistant knows them. A payment card switches to "Paid" as soon as the timeline holds an `order_paid` message or event for its order or payment, and then hides the QR code, transfer note and payment button. ## Events and data {#events-and-data} `HatangChat.events` is a `Flow` that follows re-configuration. Collect it in a lifecycle-aware scope: ```kotlin lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { HatangChat.events.collect { event -> when (event) { is HatangReady -> Log.d("chat", "ready ${event.visitorId}") is HatangMessageReceived -> showInAppBanner(event.message.content) is HatangConversion -> analytics.log(event.kind.name, event.data) is HatangProtocolOutdated -> promptUpdate() else -> Unit } } } } ``` | Event | When | |---|---| | `HatangReady(visitorId, conversationId)` | Bootstrap and authentication finished. | | `HatangOpened`, `HatangClosed` | The chat screen appeared or went away. | | `HatangMessageReceived(message)`, `HatangMessageSent(message)` | A message from the team or bot; a visitor message was stored (also from another device). | | `HatangConversationStatus(conversationId, status)` | The conversation became `open`, `pending`, `resolved` or `closed`. | | `HatangConversion(kind, data)` | `CHAT_STARTED`, `LEAD` or `PURCHASE` (with `value`, `currency`, `transaction_id` in `data`). | | `HatangSessionChanged(visitorId, reason)` | `IDENTITY_MERGED`, `RESET`, `RESTORED` or `TOKEN_REJECTED`. | | `HatangProtocolOutdated(reason)` | The gateway no longer serves this SDK version; ask the user to update the app. | For a badge, collect `HatangChat.unreadCount`: ```kotlin val unread by HatangChat.unreadCount.collectAsStateWithLifecycle() BadgedBox(badge = { if (unread > 0) Badge { Text("$unread") } }) { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Support") } ``` Send context to the inbox: ```kotlin lifecycleScope.launch { HatangChat.setData(mapOf("cart_total" to 450000, "last_order" to "SO-1001")) // shown to agents, up to 8 KB } HatangChat.pushEvent("viewed_product", mapOf("sku" to "TS-100")) // the visitor's timeline HatangChat.setScreen("ProductPage", mapOf("sku" to "TS-100")) // where the visitor is in your app ``` ## Push notifications {#push-notifications} Ha Tang sends push notifications through **your own Firebase project** (FCM HTTP v1). Upload the Firebase service account to the website's **Apps** tab under **Push notifications (Firebase)**; see [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md). Then add `com.google.firebase:firebase-messaging` and the google-services plugin to your app, and pass the token and the notification data to the SDK from your `FirebaseMessagingService`: ```kotlin title="AppMessagingService.kt" class AppMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { if (!HatangChat.isConfigured) return CoroutineScope(Dispatchers.IO).launch { runCatching { HatangChat.setPushToken(token) } } } override fun onMessageReceived(message: RemoteMessage) { if (!HatangChat.isHatangNotification(message.data)) return // not ours: your own handling // Build a notification whose tap opens the conversation val tap = PendingIntent.getActivity( this, 0, HatangChat.openIntent(this, HatangChat.conversationIdOf(message.data)), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) val notification = NotificationCompat.Builder(this, "hatang_messages") .setSmallIcon(R.drawable.ic_notification) .setContentTitle(message.notification?.title) .setContentText(message.notification?.body) .setAutoCancel(true) .setContentIntent(tap) .build() NotificationManagerCompat.from(this).notify(message.messageId.hashCode(), notification) } } ``` Register the current token once at startup, after `configure`: ```kotlin FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> lifecycleScope.launch { HatangChat.setPushToken(token) } } ``` When the app is in the background, Android shows the notification itself and a tap opens your launcher activity with the FCM data as intent extras. Pass them to the SDK: ```kotlin title="MainActivity.kt" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) intent.extras?.let { extras -> val data = extras.keySet().associateWith { extras.getString(it).orEmpty() } if (HatangChat.isConfigured && savedInstanceState == null) HatangChat.handleNotification(this, data) } // setContent { … } } ``` - `isHatangNotification(data)` checks for `hatang: "1"` (also inside a nested `data` JSON string), so your own notifications and Ha Tang's can coexist. `conversationIdOf(data)` returns the conversation a notification points at. - `handleNotification(context, data)` opens that conversation and returns `false` for payloads that are not from Ha Tang. It starts the engine when it is not running yet (cold start). - `setPushToken` is safe before the SDK is ready: registration is deferred and repeated when the visitor or the app version changes. - While the app is in the foreground and connected, or the visitor is looking at the conversation, the gateway does **not** send a push: the message arrives over the socket. - On Android 13 (API 33) and later, ask for the `POST_NOTIFICATIONS` permission. Ha Tang sends replies on the notification channel `hatang_messages`. Create a channel with this id in your app to give it a name and importance in the system settings; otherwise Android shows the notification on Firebase's fallback channel. ## Deep links {#deep-links} The "we replied" email sent to the visitor contains a link of the form `https:///c/`. If your app handles App Links for that domain (or a custom scheme with `?hatang_restore=`), restore the session from the token and open the chat: ```kotlin override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val token = intent.data?.let { HatangChat.linkTokenFromUri(it) } ?: return // not a Hatang link lifecycleScope.launch { runCatching { HatangChat.restoreSession(token) } HatangChat.open(this@MainActivity) } } ``` `linkTokenFromUri` accepts both `/c/` and `?hatang_restore=` and returns `null` for other links. `restoreSession` stores the new session token, bootstraps again and emits `HatangSessionChanged(reason = RESTORED)`. Restore tokens are single-use and expire, so do not store them. ## Theming {#theming} By default the chat takes its brand color and light or dark mode from the site settings in the dashboard (`HatangChatThemeMode.SITE`), and adjusts colors to keep text readable. Override it in `HatangConfig`, or per screen through the composables' parameters: ```kotlin HatangConfig( siteId = "site_…", appKey = "hak_…", themeMode = HatangChatThemeMode.SYSTEM, // SITE | SYSTEM | LIGHT | DARK theme = HatangChatTheme.light(accent = Color(0xFF0284C7), fontFamily = FontFamily(Font(R.font.be_vietnam_pro))), darkTheme = HatangChatTheme.dark(accent = Color(0xFF38BDF8)), ) ``` | `themeMode` | Meaning | |---|---| | `SITE` | Follow the site's theme setting (light, dark, or automatic). Default. | | `SYSTEM` | Follow the device's dark theme setting. | | `LIGHT`, `DARK` | Always light or always dark. | The UI labels every control for TalkBack, uses 48 dp touch targets, follows the system font size and reduces motion when the system asks for it. ### Settings that follow the dashboard {#dashboard-settings} Some parts of `HatangChatScreen` come straight from the site's widget settings (**Support → Channels → your website**). Changes there reach the app the next time the chat opens, with no code change: - **Header**: the subtitle under the site name (in the current language), and whether it shows the online agents' photos or the business logo (or the site name's first letter when there is no logo). - **Greeting**: on or off, and whether it shows on phones (an app always counts as a phone). When the sender is set to the online agent, that agent's name appears under the greeting while the team is online. The teaser bubble over the launcher exists only in the web widget. - **Contact buttons**: Zalo, Messenger, phone and email appear as a scrollable row of pills under the header and open the link in the matching app. Override the "Call us" and "Email" labels with the `callUs` and `emailUs` strings. With an older gateway that does not send these settings, the screen keeps its previous behavior: the greeting always shows, the header shows the agents, and there are no contact buttons. `HatangConversationView` has no top bar and follows only the greeting settings. ## Languages {#languages} Vietnamese and English are built in. The SDK picks the language in this order: `locale` in `HatangConfig`, the visitor's stored choice, the site's default language, then the device language. Change it at runtime: ```kotlin lifecycleScope.launch { HatangChat.setLocale(HatangLocale.EN) } ``` `HatangChatStrings` is a data class. Copy the built-in set and change what you need: ```kotlin HatangConfig( siteId = "site_…", appKey = "hak_…", strings = mapOf( HatangLocale.VI to HatangChatStrings.VI.copy(placeholder = "Bạn cần hỗ trợ gì?", team = "Mộc Hương"), HatangLocale.EN to HatangChatStrings.EN.copy(placeholder = "How can we help?"), ), ) ``` Strings you do not change keep their built-in text. ## Troubleshooting {#troubleshooting} | Symptom | Cause and fix | |---|---| | `HatangException(invalid_app_key 401)` at startup | The `appKey` is wrong or belongs to another site. Check the **Apps** tab of the right website; keys start with `hak_`. | | `HatangException(app_key_revoked 403)` | The key was revoked in the dashboard. Ship an update with a new key. `isAppKeyError` lets you detect both key errors. | | `state.connection` is `OUTDATED`, event `HatangProtocolOutdated` | The gateway closed the socket with code 4004: this SDK version is too old for the protocol. The SDK stops reconnecting; update `dev.hatang:chat` and ship a release. | | `rate_limit_exceeded` | 5 messages per 10 seconds and 60 per minute per visitor; 30 bootstraps per minute per install. `isTransient` and `isRateLimit` tell you to retry later. | | The emulator reaches a local API but the socket never connects | The gateway builds the WebSocket address from its own `CHAT_URL` (`localhost`), so `10.0.2.2` is not enough. Run `adb reverse tcp:3002 tcp:3002` and keep `baseUrl = "http://localhost:3002"`. On a real phone over Wi-Fi, set the gateway's `CHAT_URL` to your computer's LAN address and use that address as `baseUrl`. | | `http://` calls fail in debug builds | Android blocks cleartext traffic by default. Allow it for your development host in a debug-only network security config; production always uses `https://chat.hatang.dev`. | | No push notifications | `setPushToken` was never called, the app was in the foreground or showing the conversation, notifications are not allowed on Android 13+, or the website's **Apps** tab has no Firebase file. Use **Send a test** there to check the most recent device, and read the "Last error" field. | | Notifications land in "Miscellaneous" | The app has not created the `hatang_messages` channel. See [Push notifications](#push-notifications). | | Tapping a notification does not open the chat | The launcher activity does not pass its intent extras to `handleNotification`, or `configure` has not run yet when it does. | Pass `logger = logcatLogger()` in `HatangConfig` to see socket frames and HTTP calls in Logcat. ## Changelog {#changelog} **0.4.1** (2026-11-03): the Maven Central listing: a clearer description, this guide as project URL, Sota Labs as developer, and a LICENSE file. No code changes. **0.4.0** (2026-10-31): in-app purchases. `HatangChat.purchaseToken(userId)` gives the id to pass as `setObfuscatedAccountId`, so Google Play notifications reach the right customer; `HatangChat.reportPurchase(...)` after a purchase: Ha Tang checks the token with Google Play and shows the plan or purchase on the customer. Product cards carry `basePlanId`, `offerId`, `billingPeriod`, `trialDays` and the Google Play price set for the product. **0.3.0** (2026-10-14): the chat screen follows the site's new widget settings: a per-language subtitle under the site name, the business logo (or the site name's first letter) instead of the agents when the profile is "business", agent photos, the greeting's on/off and on-phones switches, the online agent's name under the greeting, and a row of Zalo, Messenger, call and email buttons under the header. Older gateways omit these settings and the screen looks as before. Adds `SiteConfig.header`, `SiteConfig.greetingOptions` and `SiteConfig.contactButtons` in `chat-core`, and the `callUs` and `emailUs` strings (required when you build `HatangChatStrings` with its constructor instead of `copy`). **0.2.0** (2026-10-13): a payment card switches to "Paid" as soon as the timeline holds an `order_paid` message or event for its order or payment, and paid or expired cards hide the transfer note; the VietQR code is drawn natively from the EMVCo payload (`qr_payload`, or the public checkout for older cards) because the gateway serves `qr_image_url` as SVG, with a spinner while loading and a retry button on failure (15 s timeout); product, order and payment cards no longer repeat their plain-text summary above the card, only the intro line. Adds `PaidMarks`, `PaymentCard.statusPaid` / `isPaid(marks)`, `PaymentCard.qrPayload`, `PaymentQR`, `ProductCard.intro` and `cardBubbleText` in `chat-core`, and a dependency on `com.google.zxing:core` (encoder only) in `chat`. **0.1.0** (2026-10-03): first release: chat screen, inbox and launcher in Jetpack Compose (View-based apps open them with `HatangChat.open`), photo and file attachments, signed identity, FCM push helpers, Vietnamese and English, light and dark themes. `chat-core` passes the C01–C12 conformance suite. Release notes are in the [developer changelog](https://hatang.dev/docs/changelog). See also: [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md), [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md), [Compatibility](https://hatang.dev/docs/guides/sdks/compatibility.md) for protocol versions and the minimum gateway, and the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md). Source: https://hatang.dev/docs/guides/sdks/android --- # Compatibility and versioning This page lists every Ha Tang SDK with its current version, the range that still gets fixes, the chat protocol version it speaks and the platforms it runs on. The table is generated from `sdks/compat.json` in the Ha Tang repository, the single source for these numbers; the "Current" column is read from each package's manifest when the docs are built. ## Support matrix {#support-matrix} Every SDK below speaks chat protocol version **1**. The gateway ships monthly (YYYY.MM); "Min gateway" is the oldest gateway the listed SDK versions were tested against. | SDK | Package | Status | Current | Supported | Protocol | Min gateway | Platforms | |---|---|---|---|---|---|---|---| | widget.js | [chat.hatang.dev/widget.js](https://chat.hatang.dev/widget.js) | Stable | — | always current (served by the gateway) | 1 | 2026.10 | Browsers with ES2020 (Chrome 80+, Safari 14+, Firefox 78+, Edge 80+) | | @hatang/chat-react | [npm: @hatang/chat-react](https://www.npmjs.com/package/@hatang/chat-react) | Stable | 0.1.2 | 0.1.x | 1 | 2026.10 | React 18+; Next.js 14+ (App Router and Pages) | | @hatang/sdk | [npm: @hatang/sdk](https://www.npmjs.com/package/@hatang/sdk) | Stable | 0.6.2 | 0.6.x | 1 | 2026.10 | Node 20+; Bun 1.1+; Edge runtimes with fetch (User-Agent reports node/unknown) | | hatang (Python) | [PyPI: hatang](https://pypi.org/project/hatang/) | Stable | 0.1.1 | 0.1.x | 1 | 2026.10 | Python 3.9+ (sync and asyncio, httpx) | | hatang_chat (Flutter) | [pub.dev: hatang_chat](https://pub.dev/packages/hatang_chat) | Stable | 0.6.1 | 0.6.x | 1 | 2026.10 | Flutter 3.32+; Dart 3.8+; iOS 15+; Android 6.0 (API 23)+; macOS 11+; Web; Windows 10+; Linux (secure storage falls back to preferences) | | @hatang/chat-react-native | [npm: @hatang/chat-react-native](https://www.npmjs.com/package/@hatang/chat-react-native) | Stable | 0.4.2 | 0.4.x | 1 | 2026.10 | React Native 0.76+; Expo SDK 52+ | | iOS (Swift) | [GitHub: sota-labs/hatang-chat-ios](https://github.com/sota-labs/hatang-chat-ios) | Stable | 0.4.1 | 0.4.x | 1 | 2026.10 | iOS 17+; Swift 6 / Xcode 16+; SwiftUI and UIKit (HatangChatViewController) | | Android (Kotlin) | [Maven Central: dev.hatang:chat](https://central.sonatype.com/artifact/dev.hatang/chat) | Stable | 0.4.1 | 0.4.x | 1 | 2026.10 | Android 8+ (API 26); Kotlin 1.9+ / 2.x; Jetpack Compose (BOM 2024.12+); View-based apps open the chat through HatangChat.open | | WordPress plugin | [WordPress.org: hatang-chat](https://wordpress.org/plugins/hatang-chat/) | Stable | 0.2.3 | 0.2.x | 1 | 2026.10 | WordPress 6.0+; PHP 7.4+ | ## Versioning {#versioning} - **Each package has its own version.** Every SDK follows semantic versioning and keeps its own changelog. Releases are tagged `-v`: `sdk-v*`, `chat-react-v*`, `chat-react-native-v*`, `flutter-v*`, `ios-v*`, `android-v*` and `wp-plugin-v*`. - **Before 1.0, a minor version can change the API.** All SDKs are 0.x today. A new minor version (0.4 to 0.5, say) may contain breaking changes, and its changelog says what changed and how to migrate. The "Supported" column moves to the new minor when it ships. - **Conformance before release.** Every chat SDK replays the same scenarios (C01–C12: bootstrap with an app key, auth, send and dedupe, resume and history, identify and merge, typing and read receipts, uploads, status and rating, reconnect, protocol outdated, cards, devices and app state) against a real gateway. A release needs a green run, and the suite also runs nightly. - **Security.** Report vulnerabilities to security@hatang.dev. No client SDK contains a secret: the identity secret only ever appears in server-side HMAC code. ## Protocol versions {#protocol-versions} | Protocol | Introduced | Gateway | Notes | |---|---|---|---| | 1 | 2026-10 | 2026.10+ | First stable protocol: auth → ready, send → ack, resume by `seq`, identify and session merge, typing, read receipts, uploads, cards, device registration | Clients should ignore what they do not know: unknown conversation event types are skipped, and a card with an unknown `metadata.kind` is shown as a plain message. When a gateway no longer serves a client's protocol, it closes the socket with code 4004 and every SDK reports an "update required" error (conformance scenario C10). Frame details are in [Realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md). ## Platforms {#platforms} | SDK | Minimum | |---|---| | `@hatang/sdk` | Node 20, Bun 1.1, or an edge runtime with `fetch` | | `@hatang/chat-react` | React 18, Next.js 14 | | `hatang_chat` (Flutter) | Flutter 3.32 / Dart 3.8; apps on iOS 15 and Android 6.0 (API 23) | | `@hatang/chat-react-native` | React Native 0.76, Expo SDK 52 | | iOS (Swift) | iOS 17, Swift 6 / Xcode 16 | | Android (Kotlin) | Android 8.0 (API 26), Kotlin 1.9, Compose BOM 2024.12 | | WordPress plugin | WordPress 6.0, PHP 7.4 | ## Next steps - [SDK overview](https://hatang.dev/docs/guides/sdks/overview.md): what each SDK does. - [Realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md): frames, close codes and limits. - [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md): the setup every mobile SDK shares. Source: https://hatang.dev/docs/guides/sdks/compatibility --- # Flutter SDK `hatang_chat` > **Info: Version** > > `hatang_chat` **0.6.1** · protocol v1 · Flutter 3.32+ / Dart 3.8+ · iOS 15+ · Android 6.0 (API 23)+ · web (including WebAssembly) · [pub.dev](https://pub.dev/packages/hatang_chat) · [Changelog](https://pub.dev/packages/hatang_chat/changelog) · [API reference (dartdoc)](https://hatang.dev/docs/reference/flutter/index.html) > **Note** > > Version 0.5 has the chat screen and floating launcher, identity, events, push notifications, deep links, photo and file attachments, product, order and payment cards with in-app purchase hooks, the visitor's conversation inbox, and a chat screen that follows the site's widget settings. The API may still change before 1.0. `hatang_chat` is a native chat screen for Flutter apps. It talks the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md) directly to the Ha Tang gateway (no web view) and ships a chat screen, a floating launcher, an inbox of past conversations, push notifications through your own Firebase project, and deep links. Customers who write from your app land in the same inbox as visitors from your website, Zalo, Messenger, Instagram and TikTok Shop.

Chat screen with a product card and an order card Chat screen in dark mode The visitor's conversation list In-app chat button with an unread badge

## Installation {#installation} ### Try it without an account The example app that ships with the package talks to Ha Tang's public demo inbox: an AI assistant answers questions about Ha Tang, and messages are deleted after 24 hours. ```bash dart pub unpack hatang_chat cd hatang_chat-*/example flutter run ``` With the demo, the example app receives **real push notifications** on Android and the iOS simulator with no setup: ask a question, send the app to the background, and the assistant's answer arrives as a notification. The **Push** tab shows the token, lets you paste one, and simulates tapping a notification. Pass `--dart-define=HATANG_SITE_ID=… --dart-define=HATANG_APP_KEY=…` to point the example app at your own support site. Add `--dart-define=FIREBASE_API_KEY=… FIREBASE_APP_ID=… FIREBASE_PROJECT_ID=… FIREBASE_SENDER_ID=…` (from your app's Firebase config file) to try push with your own Firebase project. ### Get an app key from the dashboard The SDK needs two values: the **Site ID** and an **app key** (`hak_…`). The key replaces the browser's Origin check: the app sends it on start, so the gateway knows which app the session comes from. 1. In the dashboard, open **Support → Channels → Add channel → Mobile app**. 2. Click **Create app key**: give it a name (for example "Acme iOS"), pick the Flutter platform, and optionally add bundle ids. 3. The key is shown once. Pass it to `HatangChat.configure(appKey:)`. The key is public like the site id (it ships inside your build) but it can be revoked. To rotate it safely, create a new key, ship an update that uses it, then revoke the old one. Sessions that use a revoked key are cut off within a minute. ### Add the package ```bash flutter pub add hatang_chat ``` ```yaml title="pubspec.yaml" dependencies: hatang_chat: ^0.6.1 ``` ### Platform permissions {#platform-permissions} The composer has an attach button: camera (phones), photo library and files (up to 10 MB). iPhone HEIC photos are converted to JPEG and large photos are scaled down to 2048 px before sending. The message shows upload progress and can be tapped to retry on failure. Received images open full screen with pinch and double-tap zoom. The **Files** entry only appears when you pass a file picker to `configure`: `pickFile: hatangFilePicker` (import `package:hatang_chat/file_picker.dart`, which uses `file_picker` 13), or your own `Future Function()` if your app pins a different `file_picker` version. To send a file from your own UI, call `controller.attach(UploadSource(bytes: …, name: 'quote.pdf', mimeType: 'application/pdf'))`. Photo and camera access needs a usage description on iOS. Android needs no extra permission with the system pickers. ```xml title="ios/Runner/Info.plist" NSPhotoLibraryUsageDescription Send photos to our support team NSCameraUsageDescription Take photos to send to our support team ``` The package runs on iOS, Android, macOS, Windows, Linux and the web. The session token is kept in the keychain / keystore (on Linux it falls back to shared preferences). ## Initialization {#initialization} Call `HatangChat.configure` once in `main()` before `runApp`. The SDK bootstraps the visitor, stores the token and opens the socket right away (`autoConnect: true`). ```dart title="lib/main.dart" import 'package:flutter/material.dart'; import 'package:hatang_chat/hatang_chat.dart'; final navigatorKey = GlobalKey(); Future main() async { WidgetsFlutterBinding.ensureInitialized(); await HatangChat.configure( siteId: 'site_…', appKey: 'hak_…', // baseUrl: Uri.parse('https://chat.hatang.dev'), // default locale: HatangLocale.en, navigatorKey: navigatorKey, // lets the SDK open the chat from a push notification ); runApp(MaterialApp(navigatorKey: navigatorKey, home: const HomePage())); } ``` | Parameter | Meaning | |---|---| | `siteId`, `appKey` | Required. From the website's **Apps** tab | | `baseUrl` | The gateway. Default `https://chat.hatang.dev`; change it only for a local gateway (see [Troubleshooting](#troubleshooting)) | | `locale` | `HatangLocale.vi` / `.en`. When unset: the visitor's stored choice, then the site default, then the device | | `themeMode`, `theme`, `darkTheme` | See [Theming](#theming) | | `strings` | Per-locale string overrides, see [Languages](#languages) | | `navigatorKey` | Lets `handleNotification` and proactive messages open the screen when you pass no `context` | | `autoConnect` | `false` to call `HatangChat.instance.connect()` yourself later (for example after a consent screen) | | `openOnProactiveMessage` | Open the chat when the team starts a conversation (default `true`) | | `commerce` | How "Buy" and "Pay" on cards behave, see [Sales cards and in-app purchase](#commerce) | | `pickFile` | Enables the **Files** attachment entry, see [Platform permissions](#platform-permissions) | | `logger`, `appInfo` | Logging, and overrides for the app name and version sent to the gateway | `HatangChat.instance.session` exposes the current `visitorId`, `conversationId`, `locale`, `isIdentified`, `email` and `name`. ## Identify visitors {#identify-visitors} When a user signs in, call `identify` with their id in your system. `hash` is the HMAC-SHA256 of `userId` with the site's identity secret, **computed on your backend** and returned to the app. Never put the secret in the app. A signed identity is merged with earlier sessions of the same person (including on the web); an unsigned one is only stored as self-reported details. See [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md). ```dart // Your backend returns {userId, hash} after sign-in await HatangChat.instance.identify(HatangUser( userId: me.id, email: me.email, name: me.name, phone: me.phone, hash: me.hatangHash, data: {'plan': 'pro', 'orders': 12}, )); ``` One line on your backend computes `hash`: ```js title="Node" const hash = require("node:crypto").createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(userId).digest("hex"); ``` ```php title="PHP" $hash = hash_hmac('sha256', $userId, getenv('HATANG_IDENTITY_SECRET')); ``` When identities merge, the SDK receives a new token, reloads the conversation and emits `HatangSessionChanged(reason: identityMerged)`. You do not need to do anything else. On sign-out, call `HatangChat.instance.reset()` so the next session on the same device is a new visitor. ## Open and embed the chat {#open-and-embed} ### Open full screen ```dart ElevatedButton( onPressed: () => HatangChat.open(context), child: const Text('Chat with us'), ) ``` `open` pushes a `MaterialPageRoute(fullscreenDialog: true)` (a Cupertino transition on iOS). Pass `conversationId:` to open an earlier conversation, and call `HatangChat.close()` to close it. Apps on go_router can declare their own route that renders `HatangChatScreen`: ```dart GoRoute(path: '/support', builder: (_, __) => const HatangChatScreen(showClose: false)), ``` ### Conversation inbox `HatangChat.openInbox(context)` opens the visitor's list of conversations (who answered, last message, time, unread count, resolved). Pull down to refresh, or place `HatangChatInbox()` in one of your tabs. `HatangChat.open(context, conversationId: id)` opens that conversation; scroll up to load older messages. Writing in a resolved conversation reopens it; a closed one is read-only, with a "New message" button. ### Floating launcher `HatangChatLauncher` is a chat bubble with an unread badge. It hides itself when the site is set to hide while offline. Its icon, image and label follow the **Launcher button** settings of the site, like the web widget; with a label it becomes a wide button such as "Support". Pass `child` to use your own icon instead. Put it in your home screen's `Stack`: ```dart Stack(children: [ const HomeBody(), HatangChatLauncher(alignment: Alignment.bottomRight, margin: const EdgeInsets.all(16)), ]) ``` ### Embed in your own screen `HatangConversationView` is the conversation body (message list, composer, attachments) without an app bar, to place under your tab bar or in a "Support" tab. It needs a `HatangChatController`: ```dart class SupportTab extends StatefulWidget { const SupportTab({super.key}); @override State createState() => _SupportTabState(); } class _SupportTabState extends State { final controller = HatangChatController(); @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Support')), body: HatangConversationView(controller: controller, bottomPadding: 56), ); } } ``` Only mount the view while it is visible (do not keep it in an `IndexedStack` that holds hidden tabs), or call `controller.setVisible(false)` when the tab is hidden: a visible chat marks messages as read. `HatangChat.instance.setBottomPadding(56)` lifts the launcher and the chat screen above your tab bar or bottom sheet; open screens update at once. The controller exposes `state`, `send(text)`, `retry(clientId)`, `pickAndSendImage()`, `pickAndSendFile()`, `loadEarlier()`, `rate(score, comment: …)` and `setVisible(bool)` (call it when the tab shows or hides so unread counts and read receipts stay right). `HatangChatInbox` lists earlier conversations (resolved ones read-only) and takes `onOpenConversation`. `HatangChat.instance.showMessage(text)` inserts a local bot bubble (a contextual greeting, for example) that is never sent to the server. ### Sales cards and in-app purchase {#commerce} Agents send product cards, order cards, payment requests, quick replies and star ratings from the inbox; the SDK renders them under the message. By default **Buy** writes "I'd like to buy …" in the composer, and a payment card shows a VietQR code, the transfer note and a **Pay** button that opens the checkout page. That is meant for physical goods and real-world services: the App Store (Guideline 3.1.3(e)) and Google Play do not allow them through in-app purchase, and in-app purchase cannot charge an arbitrary order total anyway. The App Store and Google Play require **digital goods and features used inside the app** to be sold through in-app purchase (App Store Review Guideline 3.1.1, Google Play's payments policy). For those products, hand the purchase to your store integration and hide external checkout: ```dart await HatangChat.configure( siteId: 'site_…', appKey: 'hak_…', commerce: HatangCommerce( showExternalCheckout: !Platform.isIOS, // iOS: no QR, no transfer note, no web checkout onBuyProduct: (context, product) async { // The App Store / Google Play id set for each product in the dashboard final storeId = Platform.isIOS ? product.appStoreProductId : product.googlePlayProductId; if (storeId == null) return false; // not an in-app product: keep the default behavior await myIapService.buy(storeId); // for example package:in_app_purchase return true; }, ), ); ``` Set the store product ids in the dashboard: **Products** → open a product → **Prices on the App Store and Google Play** (**App Store product ID** and **Google Play product ID**). Product cards carry both ids. Return `true` from a hook when you handled it. With `showExternalCheckout` off and no `onPay`, a payment card only shows the amount and a line saying the team will tell the customer how to complete the payment. The SDK depends on no store library; `HatangConversationView(commerce: …)` overrides the global policy for one embedded screen. > The whole flow, including store setup, reporting renewals and restores, importing existing subscribers and testing, is in [In-app purchases and subscriptions](https://hatang.dev/docs/guides/support/in-app-purchases.md). **Subscriptions.** Inside your app the card shows the store price you set for that platform (App Store on iOS, Google Play on Android); subscription cards also carry `billingPeriod`, `trialDays`, and for Google Play `basePlanId` and `offerId`. Pass `HatangChat.instance.purchaseToken(userId)` as `applicationUserName` in `in_app_purchase`'s `PurchaseParam` (it becomes StoreKit's appAccountToken and Play's obfuscated account id), and after a successful purchase call `HatangChat.instance.reportPurchase(platform: Platform.isIOS ? 'ios' : 'android', storeProductId: …, transactionId: …)` with the store's proof: `signedTransaction: purchase.verificationData.serverVerificationData` on iOS (StoreKit 2 enabled in `in_app_purchase_storekit`), or `purchaseToken: purchase.verificationData.serverVerificationData, productType: 'subs'` (`'inapp'` for one-time products) on Android. Hatang checks it with Apple or Google before recording it on the customer, and acknowledges Google Play purchases. Then connect the app under **Channels → your app → Subscriptions from the App Store and Google Play**. ## Events and data {#events-and-data} ```dart final sub = HatangChat.instance.events.listen((e) { switch (e) { case HatangReady(:final visitorId): debugPrint('ready $visitorId'); case HatangMessageReceived(:final message): showInAppBanner(message.content); case HatangConversion(:final kind, :final data): analytics.log(kind.name, data); case HatangProtocolOutdated(): promptUpdate(); case _: break; } }); ``` | Event | When | |---|---| | `HatangReady` | Bootstrap and authentication finished | | `HatangOpened`, `HatangClosed` | The chat screen appears or goes away | | `HatangMessageReceived`, `HatangMessageSent` | A message from the team or bot; a visitor message was stored (including from another device) | | `HatangConversationStatus` | `open`, `pending`, `resolved`, `closed` | | `HatangConversion` | `chatStarted`, `lead`, `purchase` (with `value`, `currency`, `transaction_id`) | | `HatangSessionChanged` | `identityMerged`, `reset`, `restored`, `tokenRejected` | | `HatangProtocolOutdated` | The gateway no longer serves this version; ask the user to update the app | Other streams: `unreadCount` (and `unreadCountListenable` for badges), `availability` (team online, within business hours, next opening time) and `connectionState` (`disconnected`, `connecting`, `connected`, `outdated`). ```dart ValueListenableBuilder( valueListenable: HatangChat.instance.unreadCountListenable, builder: (_, n, __) => Badge(label: Text('$n'), isLabelVisible: n > 0, child: const Icon(Icons.chat_bubble_outline)), ) ``` Send data to the inbox: ```dart await HatangChat.instance.setData({'cart_total': 450000, 'last_order': 'SO-1001'}); // shown to agents, up to 8 KB await HatangChat.instance.pushEvent('viewed_product', {'sku': 'TS-100'}); // the visitor's timeline HatangChat.instance.setScreen('ProductPage', params: {'sku': 'TS-100'}); // agents see where the visitor is ``` The SDK sends `app.state` when the app goes to the background and comes back, so a backgrounded visitor counts as offline and replies go out as push notifications. ## Push notifications {#push-notifications} Ha Tang sends push through **your own Firebase project** (FCM HTTP v1): upload the service account file on the website's **Apps** tab (see [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md)). The SDK does not depend on Firebase; your app uses `firebase_messaging` and hands the token to the SDK. ```bash flutter pub add firebase_core firebase_messaging ``` ```dart title="lib/push.dart" import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:hatang_chat/hatang_chat.dart'; Future wirePush() async { final messaging = FirebaseMessaging.instance; await messaging.requestPermission(); // Register the device; the SDK keeps the token and registers later if there is no session yet final token = await messaging.getToken(); if (token != null) await HatangChat.instance.setPushToken(token, provider: PushProvider.fcm); messaging.onTokenRefresh.listen((t) => HatangChat.instance.setPushToken(t, provider: PushProvider.fcm)); // App in the background, the user taps the notification FirebaseMessaging.onMessageOpenedApp.listen((m) { if (HatangChat.isHatangNotification(m.data)) HatangChat.instance.handleNotification(m.data); }); // Cold start from a notification: the SDK holds the request until it is ready, then opens the conversation final initial = await messaging.getInitialMessage(); if (initial != null && HatangChat.isHatangNotification(initial.data)) { await HatangChat.instance.handleNotification(initial.data); } } ``` Call `wirePush()` after `HatangChat.configure` and `Firebase.initializeApp`. `isHatangNotification` checks `data["hatang"] == "1"` (a nested `data` is accepted too), so your own notifications and Ha Tang's can coexist. `handleNotification` opens the conversation in `data["conversation_id"]`; pass `context:` or let the SDK use `navigatorKey`. It returns as soon as the chat is open (or the request is held during a cold start), without waiting for the visitor to close the chat, so `await` in start-up code does not hang the app. While the app is in the foreground and connected, the gateway does **not** send push (messages arrive over the socket), so `FirebaseMessaging.onMessage` rarely sees Ha Tang data. Call `clearPushToken()` on sign-out; `reset()` registers the token again for the new visitor. **Android 8+**: Ha Tang posts replies to the `hatang_messages` notification channel. Create a channel with this id (for example with `flutter_local_notifications`) to set its name and importance in the device settings; otherwise Android shows the notification in Firebase's fallback channel. **iOS**: besides the iOS app in Firebase, you need an App ID that matches the bundle ID exactly and an APNs key uploaded to Firebase (see [Push notifications → iOS apps](https://hatang.dev/docs/guides/sdks/push-notifications.md#ios-apps)). Then in Xcode: 1. Runner → **Signing & Capabilities** → choose the Team that owns the App ID. 2. **+ Capability → Push Notifications** (adds `aps-environment` to the entitlements). 3. **+ Capability → Background Modes** → check **Remote notifications**. The example app (`example/ios`) has all three configured for reference. ## Deep links {#deep-links} The "we replied" email sent to visitors contains a link `https:///c/`. If your app handles universal links / app links for that domain (or a custom scheme with `?hatang_restore=`), restore the session from the token and open the chat: ```dart // app_links, go_router or any source of Uri Future onUri(Uri uri, BuildContext context) async { final token = HatangChat.linkTokenFromUri(uri); if (token == null) return; // not a Hatang link await HatangChat.instance.restoreSession(token); if (context.mounted) await HatangChat.open(context); } ``` `linkTokenFromUri` accepts both `/c/` and `?hatang_restore=`. `restoreSession` stores the new session token, bootstraps again and emits `HatangSessionChanged(reason: restored)`. Restore tokens are single-use and expire, so do not store them. ## Theming {#theming} By default the SDK takes the brand color and light/dark mode from the site settings in the dashboard (`themeMode: HatangChatThemeMode.site`). Override them with `theme` / `darkTheme`: ```dart await HatangChat.configure( siteId: 'site_…', appKey: 'hak_…', themeMode: HatangChatThemeMode.system, // site | light | dark | system theme: HatangChatTheme.light(accent: const Color(0xFF0284C7), fontFamily: 'Inter'), darkTheme: HatangChatTheme.dark(accent: const Color(0xFF38BDF8)), ); ``` `HatangChatTheme` is a `ThemeExtension`: put it in your app's `ThemeData.extensions` so `HatangChatScreen` and `HatangConversationView` follow your theme when you do not pass one to `configure`; `copyWith` and `lerp` are available. The screens wrap themselves in `Theme`, `Material` and `Localizations.override`, so they run inside a `CupertinoApp` without extra delegates. Text and icons keep at least 4.5:1 contrast with the color you pick (the SDK darkens or lightens it when needed), respect the system font scale up to 200%, and reduce motion when the device asks for it. ### Follow the widget settings in the dashboard {#widget-settings} Some parts of `HatangChatScreen` come straight from the site's widget settings (**Support → Channels → your website**). Change them in the dashboard and the app picks them up the next time the chat opens, with no code change: - **Header**: the subtitle under the site name (in the current language) and the profile: photos of online agents, or the business logo (the first letter of the site name when there is no logo). - **Greeting**: on or off, and whether it shows on phones (the SDK counts every platform as a phone, including tablets, desktop and web). When the sender is set to an online agent, that agent's name shows under the greeting while the team is online. The teaser over the launcher exists only in the web widget. - **Contact buttons**: Zalo, Messenger, phone and email appear as a horizontally scrolling row of buttons under the header and open in the matching app on the device (through `url_launcher`). Override the "Call us" and "Email" labels with the `callUs` and `emailUs` strings. With an older gateway that does not send these settings, the screen stays as before: the greeting always shows, the header shows agents, and there are no contact buttons. `HatangConversationView` (no app bar) only follows the greeting settings. ## Languages {#languages} The SDK ships Vietnamese and English. The language is chosen in this order: `locale` in `configure`, the visitor's stored choice, the site's default language, then the device language. Switch at runtime: ```dart await HatangChat.instance.setLocale(HatangLocale.en); ``` Override individual strings per language (strings you do not override keep their defaults): ```dart await HatangChat.configure( siteId: 'site_…', appKey: 'hak_…', strings: { HatangLocale.en: HatangChatStrings.en.copyWith(placeholder: 'How can we help?', team: 'Acme Support'), HatangLocale.vi: HatangChatStrings.vi.copyWith(placeholder: 'Bạn cần hỗ trợ gì?'), }, ); ``` Useful keys include `placeholder`, `team`, `callUs`, `emailUs` and `poweredBy` (the "Chat by hatang.dev" line under the composer). In your own widgets, `HatangChatLocalizations.of(context).strings` gives the strings in use; add `HatangChatLocalizations.delegate` to your `MaterialApp`'s `localizationsDelegates` if you want to look them up outside the chat screen. ## Troubleshooting {#troubleshooting} | Symptom | Cause and fix | |---|---| | `HatangException(invalid_app_key 401)` on start | `appKey` is wrong or belongs to another site. Check the **Apps** tab of the right website; keys start with `hak_`. | | `HatangException(app_key_revoked 403)` | The key was revoked in the dashboard. Ship an update with a new key, and show an "update the app" message to users of the old build (`HatangException.isAppKeyError`). | | `connectionState` is `outdated`, `HatangProtocolOutdated` event | The gateway closed the socket with code 4004: the SDK is too old for the protocol. The SDK stops reconnecting; upgrade `hatang_chat` and ship a release. | | Android emulator reaches the local API but the socket does not connect | The gateway builds the WebSocket address from its own `CHAT_URL` (`localhost`), so `10.0.2.2` is not enough. Run `adb reverse tcp:3002 tcp:3002` and keep `baseUrl: Uri.parse('http://localhost:3002')`. For a real phone on Wi-Fi, set the gateway's `CHAT_URL` to your dev machine's LAN IP and use that same address as `baseUrl`. | | iOS cannot call `http://` during development | App Transport Security blocks plain HTTP. Add an `NSAppTransportSecurity` / `NSAllowsLocalNetworking` exception to `Info.plist` for debug builds only; production always uses `https://chat.hatang.dev`. | | Flutter web: CORS error or 401 although the key is right | The browser sends an Origin and the `x-hatang-app-key` header; the page's origin must be allowed for the site (the website's domain, or **Extra allowed origins** in its settings) for the preflight to pass. On the web, consider the [web widget](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md) instead of the Flutter SDK. | | No push notifications | Check the decision order on [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md): no push while the app is in the foreground or the visitor is viewing the conversation; the website's **Apps** tab must have a Firebase file; click **Send a test** to reach the most recent device. | | iOS: last error is APNs `TopicDisallowed` | The App ID in Apple Developer does not match the bundle ID exactly (a trailing dot counts), or the APNs key belongs to another team. Create an App ID with the exact bundle ID and Push Notifications on; no rebuild is needed. | | iOS: `firebase_messaging/apns-token-not-set` | The Push Notifications capability is missing, or (on the simulator) its push service is stuck: restart the simulator. The simulator receives real push from iOS 16 on Apple silicon Macs. | | Android: notifications land in "Miscellaneous" | The app did not create the `hatang_messages` channel; see [Push notifications](#push-notifications). | | `dart pub publish` refuses with "Potential leak of Google API Key" | The app-side Firebase config is not a secret; list the file that contains it under `false_secrets` in `pubspec.yaml`. | | `rate_limit_exceeded` | 5 messages per 10 seconds and 60 per minute per visitor; 30 bootstraps per minute per install. `HatangException.isTransient` says it is worth retrying later. | Pass `logger: HatangLogger.console` to `configure` to see socket frames and HTTP calls in the console while debugging. ## Changelog {#changelog} **0.6.1**: the pub.dev listing: a clearer description, the product page as homepage, `in-app-chat` and `zalo` topics, and an About Ha Tang section in the README. No code changes. **0.6.0**: in-app purchases. `HatangChat.purchaseToken(userId)` (and top-level `purchaseToken(siteId, userId)`) gives the app account token for StoreKit / Google Play; `HatangChat.reportPurchase(...)` after a purchase, with `signedTransaction` (iOS) or `purchaseToken` + `productType` (Android): Ha Tang verifies it with the store and shows the plan or purchase on the customer. `ProductCardItem` carries `basePlanId`, `offerId`, `billingPeriod`, `trialDays`. **0.5.0**: the chat screen follows more of the site's widget settings (older gateways that omit them keep the previous behavior). Header: the subtitle under the site name and the business logo (or the site name's first letter) instead of the agents when the profile is "business"; agent avatars show their photos. Greeting: hidden when turned off or off on phones; with the "online agent" sender, the first online agent's name shows under it. Contact buttons: a scrolling row of Zalo, Messenger, phone and email buttons under the header, opened with `url_launcher`. Adds `SiteConfig.header`, `SiteConfig.greetingOptions`, `SiteConfig.contactButtons`, `greetingRowFor` and the `callUs` / `emailUs` strings. **0.4.0**: a payment card switches to "Paid" as soon as the timeline has an `order_paid` message or event for its order or payment; the VietQR code is drawn natively from the EMVCo payload (`qr_payload`, or from the public checkout for older cards) because the gateway serves `qr_image_url` as SVG, with a retry button on failure; product, order and payment cards no longer repeat their plain-text summary in a bubble above the card, only the intro line (`metadata.intro`); paid or expired cards hide the transfer note. Adds `PaidMarks`, `PaymentCard.statusPaid` / `isPaid(marks)`, `MessageRow.paid`, `PaymentCard.qrPayload`, `PaymentQr`, `HatangChatController.resolvePaymentQr`, `ProductCard.intro` and `cardBubbleText`; depends on `qr` (pure Dart) to draw the code. **0.3.2**: a "Chat by hatang.dev" line under the composer, like the web widget (plain text, not a link; change it with the `poweredBy` string). **0.3.1**: `HatangChatLauncher` takes its icon, image and label from the site's **Launcher button** settings (a label makes it a wide button such as "Support"); `child` still overrides it; `SiteConfig.launcher`. **0.3.0** (breaking): the **Files** attachment entry only appears when you pass `pickFile` to `configure` (`pickFile: hatangFilePicker` from `package:hatang_chat/file_picker.dart`, or your own function), so apps that pin another `file_picker` version can install the SDK; camera and photo library are unchanged. Wider version ranges for `flutter_secure_storage` (10–11), `image_picker` (from 1.1.2) and `flutter_image_compress` (from 2.3.0). `reset()` before the chat ever connected (`autoConnect: false`) now forgets the right visitor. **0.2.4**: removes a stray "Load earlier messages" row in conversations an agent opened with a greeting (the SDK uses the gateway's `timeline_has_more`); the example app receives push from the demo on Android and iOS with no parameters. **0.2.3**: `handleNotification(data, context: …)` returns at once instead of waiting for the visitor to close the chat (it used to hang start-up code that awaited it); the example app gets a Push tab and optional Firebase Messaging; docs for the iOS steps, the Android `hatang_messages` channel and `adb reverse` for the Android emulator. **0.2.2**: screenshots on pub.dev and in the README; the example app runs against the public demo inbox when given no parameters. **0.2.1**: WebAssembly support (`flutter build web --wasm`) and a fix for Flutter web failing to connect; text on the brand color picks black or white by WCAG contrast, and the brand color used as text is adjusted to 4.5:1; card buttons use the app's font; banners, the composer and order cards stay accessible at 200% font size. **0.2.0**: product, order and payment (VietQR) cards, quick replies and ratings; `HatangCommerce` for in-app purchase with App Store / Google Play product ids; photo and file attachments (camera, photo library, files; upload progress; HEIC to JPEG; large photos scaled to 2048 px; full-screen image viewer); `HatangChatInbox`, `HatangChat.openInbox` and opening a conversation by id; `setBottomPadding` applies to open screens at once; the User-Agent includes the OS version. **0.1.0**: `configure` with a site id and app key; `HatangChatScreen` and `HatangChat.open`; the session API (`identify`, `setData`, `pushEvent`, `setLocale`, `setScreen`, `reset`, `restoreSession`, `linkTokenFromUri`); the `events`, `unreadCount`, `availability` and `connectionState` streams; `app.state` on background and foreground; push hooks; offline form and email prompt; Vietnamese and English; light and dark themes from the site color. The full history is in the [changelog on pub.dev](https://pub.dev/packages/hatang_chat/changelog). See also: [Compatibility](https://hatang.dev/docs/guides/sdks/compatibility.md) for the protocol version and minimum gateway, [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md), [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md) and the [React Native SDK](https://hatang.dev/docs/guides/sdks/react-native.md). Source: https://hatang.dev/docs/guides/sdks/flutter --- # iOS SDK `HatangChat` `HatangChat` adds a native support chat to your iOS app. It talks the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md) to the Ha Tang gateway directly (no web view) and uses only system frameworks: URLSession, SwiftUI and the Keychain. Customers who write from your app land in the same inbox as visitors from your website, Zalo, Messenger, Instagram and TikTok Shop. > **Info: Version** > > `HatangChat` **0.4.1** · protocol v1 · iOS 17+ · Swift 6 / Xcode 16+ · [Swift Package](https://github.com/sota-labs/hatang-chat-ios) · no third-party dependencies · [Changelog](https://github.com/sota-labs/hatang-chat-ios/blob/main/CHANGELOG.md) · [API reference (DocC)](https://hatang.dev/docs/reference/ios/documentation) > **Note** > > Version 0.3 includes the SwiftUI chat screen and inbox (plus a UIKit view controller), a floating launcher, signed identity, photo and file attachments, product, order and payment cards, push notifications and session-restoring deep links. The API may still change before 1.0. ## Installation {#installation} ### Get an app key from the dashboard The SDK needs two values: the **Site ID** and an **app key** (`hak_…`). The app key replaces the browser's Origin check: the app sends it when it starts, so the gateway knows which app a session comes from. 1. In the dashboard, open **Support → Channels → Add channel → Mobile app**. 2. Click **Create app key**, give it a name (for example "Moc Huong iOS") and pick the iOS platform. The bundle ID is optional and only a reminder. 3. The key is shown once. Pass it to `HatangChat.configure(appKey:)`. App keys are public in the same way the site ID is (they ship inside your build), but you can revoke them. To rotate a key safely, create a new one, ship an update that uses it, then revoke the old key. Sessions that use a revoked key are cut off within a minute. ### Add the package In Xcode, choose **File → Add Package Dependencies…**, paste `https://github.com/sota-labs/hatang-chat-ios` and add the **HatangChat** product to your app target. Or declare it in `Package.swift`: ```swift title="Package.swift" dependencies: [ .package(url: "https://github.com/sota-labs/hatang-chat-ios", from: "0.4.1"), ], targets: [ .target(name: "MyApp", dependencies: [ .product(name: "HatangChat", package: "hatang-chat-ios"), ]), ] ``` The package has two products: | Product | Contains | |---|---| | `HatangChat` | The `HatangChat` facade and the SwiftUI / UIKit user interface. Use this one. | | `HatangChatCore` | Foundation only: protocol types, REST client, WebSocket and the `ChatEngine` actor. For apps that build their own UI. | ### Attachments and permissions The composer's attach button offers the photo library (SwiftUI `PhotosPicker`) and files (the system file importer). Photos in HEIC are converted to JPEG and scaled down to 2048 px before upload; files can be up to 10 MB. The SDK does not open the camera, and the system pickers it uses need no extra `Info.plist` entries. ## Initialization {#initialization} Call `HatangChat.configure` once at launch. With `autoConnect` (the default), the SDK bootstraps the visitor, stores the token in the Keychain and opens the socket in the background right away. ```swift title="MyApp.swift" import HatangChat import SwiftUI @main struct MyApp: App { init() { HatangChat.configure( siteId: "site_…", appKey: "hak_…", locale: .vi, logger: .console // while integrating; the default is .silent ) } var body: some Scene { WindowGroup { ContentView() } } } ``` | Parameter | Default | Meaning | |---|---|---| | `siteId`, `appKey` | required | From the website's **Apps** tab. Empty values stop the app with a precondition failure. | | `baseURL` | `https://chat.hatang.dev` | The gateway. Change it only for a local gateway (see [Troubleshooting](#troubleshooting)). | | `locale` | `nil` | `.vi` or `.en`. Without it: the visitor's stored choice, then the site's default, then the device. | | `themeMode`, `theme`, `darkTheme` | `.site`, `nil`, `nil` | See [Theming](#theming). | | `strings` | `nil` | Wording overrides per locale; see [Languages](#languages). | | `autoConnect` | `true` | `false` to call `await HatangChat.connect()` yourself later (for example after a consent screen). | | `openOnProactiveMessage` | `true` | Show the chat when the team starts a conversation that asks to open on the visitor's device (right away when the app is active, otherwise when it becomes active). | | `logger` | `.silent` | `.console` logs socket frames and HTTP calls. | | `appInfo` | `HatangAppInfo.current()` | Overrides the app and device details sent to the gateway. | | `commerce` | `.standard` | How "Buy" and "Pay" on chat cards behave; see [Sales cards and in-app purchase](#commerce). | Calling `configure` again replaces the previous engine. Other members of the facade: | Member | Meaning | |---|---| | `HatangChat.isConfigured` | `configure` has run. | | `await HatangChat.connect()` / `disconnect()` | Bootstrap and connect, or close the socket while staying configured. | | `HatangChat.dispose()` | Release the engine and all resources; call `configure` again to use the SDK. | | `HatangChat.model` | The `@Observable` `HatangChatModel` the built-in views use: `state`, `unread`, `connection`, `availability`, `locale`, `online`… | | `HatangChat.session` | The current visitor id, conversation id and locale. | | `HatangChat.engine` | The underlying `ChatEngine` actor, for advanced cases. | The SDK follows the app lifecycle on its own: when the app moves to the background it tells the gateway (`app.state`), so new replies go out as push notifications instead of over the socket. The Keychain survives an uninstall, so on the first launch after a reinstall the SDK clears its Keychain items and the app starts as a new visitor, the same as on Android. ## Identify visitors {#identify-visitors} When the user signs in, call `identify` with the id from your own system. `hash` is the HMAC-SHA256 (hex) of `userId` with the site's **identity secret**. Compute it **on your server** and return it to the app; never put the secret in the app. A signed identity is merged with the same person's earlier sessions (on the web too) and shows as verified in the inbox; without a signature the details are stored as self-reported. See [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md). ```swift // Your backend returns { userId, hash } after sign-in do { try await HatangChat.identify(HatangUser( userId: me.id, email: me.email, name: me.name, phone: me.phone, hash: me.hatangHash, data: ["plan": "pro", "orders": 12] )) } catch let error as HatangError { print(error.code, error.message) } ``` Computing `hash` on your backend takes one line: ```js title="Node.js" const hash = require("node:crypto").createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(userId).digest("hex"); ``` ```php title="PHP" $hash = hash_hmac('sha256', $userId, getenv('HATANG_IDENTITY_SECRET')); ``` When the identity already belongs to another visitor (the user chatted on another device, for example), the gateway merges the sessions: the SDK receives a new token, reloads the conversation and emits `.sessionChanged(reason: .identityMerged)`. You do not need to do anything else. ### Sign-out ```swift await HatangChat.clearPushToken() // optional: stop pushes to this device await HatangChat.reset() ``` `reset()` revokes the old token and starts a fresh visitor, so the next person on the same device does not see the previous user's conversations. Failures are logged rather than thrown. ## Open and embed the chat {#open-and-embed} ### Present the chat ```swift HatangChat.present() // the active conversation, over the top-most view controller HatangChat.present(conversationId: "cnv_…") // a specific conversation HatangChat.presentInbox() // the visitor's conversation list HatangChat.dismiss() ``` Both `present` and `presentInbox` take an optional `from:` view controller; without it the SDK presents over the key window's top-most view controller. When the chat is already on screen, `present(conversationId:)` switches to that conversation instead of stacking a second screen. `HatangChat.isPresented` tells you whether an SDK-presented chat is visible. ### Floating launcher `HatangChatLauncher` is a chat button with an unread badge. It uses the site's brand color and the icon, image and label configured under **Launcher button** in the site settings (a label turns it into a capsule such as "Support"), and hides itself while the team is away when the site asks for that. It renders nothing before `configure`. ```swift ContentView() .overlay(alignment: .bottomTrailing) { HatangChatLauncher().padding() } ``` | Parameter | Default | Meaning | |---|---|---| | `model` | `HatangChat.model` | Model override. | | `showBadge` | `true` | Show the unread count. | | `systemImage` | `nil` | An SF Symbol that replaces the site's icon and label. | | `action` | `HatangChat.present()` | What a tap does. | ### Embed in your own navigation `HatangChatView` is the conversation screen as a SwiftUI view, and `HatangChatInbox` is the conversation list. Place them in a tab or push them onto your own `NavigationStack`: ```swift TabView { HomeView().tabItem { Label("Home", systemImage: "house") } HatangChatView(title: "Support") .tabItem { Label("Support", systemImage: "bubble.left.and.bubble.right") } } // Pushed onto your stack: do not wrap it in a second NavigationStack NavigationLink("Chat with us") { HatangChatView(embedInNavigationStack: false) } ``` | View | Parameters | |---|---| | `HatangChatView` | `conversationId` (default: the active one), `model`, `title` (default: the site name), `embedInNavigationStack` (default `true`), `onClose` (shows a close button) | | `HatangChatInbox` | `model`, `onOpenConversation` (default: push `HatangChatView`), `onClose` | UIKit apps use `HatangChatViewController(conversationId:model:)` for the conversation and `HatangChatViewController.inbox(model:)` for the list. Both present full screen with a close button and expose `onDismiss`. ```swift let chat = HatangChatViewController() chat.onDismiss = { print("chat closed") } present(chat, animated: true) ``` `HatangChat.showMessage("Need help choosing a size?", senderName: "Moc Huong")` adds a local assistant bubble that is never sent to the gateway, for example a greeting that depends on the screen. ### Sales cards and in-app purchase {#commerce} Agents send product cards, order cards, payment requests, quick replies and rating requests from the inbox; the SDK draws them under the message. By default, **Buy** on a product card writes "I'd like to buy …" into the composer, and a payment card shows a VietQR code (drawn by the SDK from the payment data), the transfer note and a **Pay** button that opens the web checkout. - **Physical goods and real-world services** (tea, clothing, delivery, bookings…): keep the default. The App Store requires these to be paid outside in-app purchase (App Store Review Guideline 3.1.3(e)), and in-app purchase cannot charge an arbitrary order total anyway. - **Digital goods and features used inside your app** (memberships, coins, in-app courses…): these must be sold through in-app purchase (Guideline 3.1.1). Hand the purchase to StoreKit and turn off external checkout: ```swift HatangChat.configure( siteId: "site_…", appKey: "hak_…", commerce: HatangCommerce( onBuyProduct: { product in // The App Store product ID entered for this product in the dashboard guard let id = product.appStoreProductId else { return false } // not an in-app product: default behavior Task { try await Store.shared.buy(id) } // your StoreKit 2 code return true }, showExternalCheckout: false // no QR code, no transfer note, no web checkout ) ) ``` | `HatangCommerce` field | Meaning | |---|---| | `onBuyProduct` | **Buy** on a product card. Return `true` when you handled it; `false` falls back to prefilling the composer. | | `onPay` | **Pay** on a payment card. Return `true` when you handled it; `false` falls back to the web checkout. | | `showExternalCheckout` | Default `true`. `false` hides the VietQR code, the transfer note and the web checkout. | Enter the store product IDs in the dashboard under **Products** → open a product → **Prices on the App Store and Google Play**. Product cards then carry `appStoreProductId` and `googlePlayProductId`. With `showExternalCheckout: false` and no `onPay`, a payment card shows only the amount and a note that the team will explain how to pay. > The whole flow, including store setup, reporting renewals and restores, importing existing subscribers and testing, is in [In-app purchases and subscriptions](https://hatang.dev/docs/guides/support/in-app-purchases.md). **Subscriptions.** A customer in your iOS app sees the App Store price you set for the product, and subscription cards also carry `billingPeriod` (`P1M`, `P1Y`…) and `trialDays`. So that App Store Server Notifications reach this customer in Hatang, buy with their app account token, then tell the team: ```swift let token = HatangChat.purchaseToken(userId: currentUser.id) // the id you sign in with (identify) let result = try await product.purchase(options: token.map { [.appAccountToken($0)] } ?? []) if case .success(let v) = result, case .verified(let tx) = v { await HatangChat.reportPurchase(storeProductId: tx.productID, transactionId: String(tx.originalID), signedTransaction: v.jwsRepresentation) await tx.finish() } ``` With `signedTransaction` Hatang checks the transaction with Apple before recording it on the customer (a forged one is refused), so the plan shows right away instead of waiting for Apple's notification. Consumables and lifetime unlocks are recorded too. Purchases made before the customer signs in move to them when you call `identify`. Then connect the app under **Channels → your app → Subscriptions from the App Store and Google Play**: the customer's plan, renewals, cancellations and refunds show on their profile and the AI assistant knows them. A payment card switches to "Paid" as soon as the timeline holds an `order_paid` message or event for its order or payment, and then hides the QR code, transfer note and **Pay** button. ## Events and data {#events-and-data} `HatangChat.events` is an `AsyncStream` for the current engine. Subscribe after `configure`: ```swift Task { for await event in HatangChat.events { switch event { case .ready(let visitorId, _): print("ready", visitorId) case .messageReceived(let message): showInAppBanner(message.content) case .conversion(let conversion): Analytics.log(conversion.kind, conversion.data) case .protocolOutdated: promptUpdate() default: break } } } ``` | Event | When | |---|---| | `.ready(visitorId:conversationId:)` | Bootstrap and authentication finished. | | `.opened`, `.closed` | The chat screen appeared or went away. | | `.messageReceived(_)`, `.messageSent(_)` | A message from the team or bot; a visitor message was stored (also from another device). | | `.conversationStatus(conversationId:status:)` | The conversation became `open`, `pending`, `resolved` or `closed`. | | `.conversion(_)` | `HatangConversion` with `kind` `.chatStarted`, `.lead` or `.purchase` (with `value`, `currency`, `transaction_id` in `data`). | | `.sessionChanged(visitorId:reason:)` | `.identityMerged`, `.reset`, `.restored` or `.tokenRejected`. | | `.protocolOutdated(reason:)` | The gateway no longer serves this SDK version; ask the user to update the app. | For badges, read `HatangChat.unreadCount` or observe `HatangChat.model.unread` in SwiftUI: ```swift struct RootView: View { let model = HatangChat.model var body: some View { TabView { HomeView().tabItem { Label("Home", systemImage: "house") } HatangChatView() .tabItem { Label("Support", systemImage: "bubble.left") } .badge(model.unread) } } } ``` Send context to the inbox: ```swift try await HatangChat.setData(["cart_total": 450000, "last_order": "SO-1001"]) // shown to agents, up to 8 KB HatangChat.pushEvent("viewed_product", data: ["sku": "TS-100"]) // the visitor's timeline HatangChat.setScreen("ProductPage", params: ["sku": "TS-100"]) // where the visitor is in your app ``` ## Push notifications {#push-notifications} Ha Tang sends push notifications through **your own Firebase project** (FCM HTTP v1), on iOS too. Upload the Firebase service account to the website's **Apps** tab under **Push notifications (Firebase)** and set up APNs in Firebase; see [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md) and its [iOS apps](https://hatang.dev/docs/guides/sdks/push-notifications.md#ios-apps) section. The SDK does not depend on Firebase: add Firebase Messaging to your app and pass the FCM token to the SDK. > **Caution** > > `HatangChat.setPushToken(_ deviceToken: Data)` registers a raw APNs device token. The gateway stores it, but it delivers only through FCM today, so a device registered only with an APNs token gets no notifications. Use `setPushToken(fcmToken:)`. Direct APNs delivery without Firebase is planned. ```swift title="AppDelegate.swift" import FirebaseCore import FirebaseMessaging import HatangChat import UIKit final class AppDelegate: NSObject, UIApplicationDelegate, MessagingDelegate, UNUserNotificationCenterDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { FirebaseApp.configure() Messaging.messaging().delegate = self UNUserNotificationCenter.current().delegate = self UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in } application.registerForRemoteNotifications() return true } // Register the device with Hatang (safe before the SDK is ready; the SDK registers again when the visitor changes) func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { guard let fcmToken else { return } Task { await HatangChat.setPushToken(fcmToken: fcmToken) } } // The user tapped a notification func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { let info = response.notification.request.content.userInfo if HatangChat.isHatangNotification(info) { HatangChat.handleNotification(info) } } } ``` In a SwiftUI app, attach the delegate with `@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate`. - `isHatangNotification(_:)` checks for `hatang: "1"` in `userInfo`, so your own notifications and Ha Tang's can coexist. `conversationId(of:)` returns the conversation a notification points at. - `handleNotification(_:)` opens that conversation and returns `false` for payloads that are not from Ha Tang. It can be called from any thread. On a cold start it starts the engine and shows the chat once the app is active. - While the app is in the foreground and connected, or the visitor is looking at the conversation, the gateway does **not** send a push: the message arrives over the socket. - Call `clearPushToken()` on sign-out if this device should stop receiving notifications. In Xcode, select your target → **Signing & Capabilities** and add **Push Notifications** and **Background Modes → Remote notifications**. ## Deep links {#deep-links} The "we replied" email sent to the visitor contains a link of the form `https:///c/`. If your app handles universal links for that domain (or a custom scheme with `?hatang_restore=`), restore the session from the token and open the chat: ```swift ContentView() .onOpenURL { url in guard let token = HatangChat.linkToken(from: url) else { return } // not a Hatang link Task { try? await HatangChat.restoreSession(token) HatangChat.present() } } ``` `linkToken(from:)` accepts both `/c/` and `?hatang_restore=` and returns `nil` for other URLs. `restoreSession(_:)` stores the new session token, bootstraps again and emits `.sessionChanged(reason: .restored)`. Restore tokens are single-use and expire, so do not store them. ## Theming {#theming} By default the chat takes its brand color and light or dark mode from the site settings in the dashboard (`themeMode: .site`), and raises contrast automatically in dark mode. Override it in `configure`: ```swift HatangChat.configure( siteId: "site_…", appKey: "hak_…", themeMode: .system, // .site | .system | .light | .dark theme: .light(accent: Color(red: 0.01, green: 0.52, blue: 0.78)), darkTheme: .dark(accent: Color(red: 0.22, green: 0.74, blue: 0.97)) ) ``` | `themeMode` | Meaning | |---|---| | `.site` | Follow the site's theme setting (light, dark, or automatic). Default. | | `.system` | Follow the device's appearance. | | `.light`, `.dark` | Always light or always dark. | The UI has VoiceOver labels on every control and announces new messages, follows Dynamic Type and respects Reduce Motion. ### Settings that follow the dashboard {#dashboard-settings} Some parts of the chat come straight from the site's widget settings (**Support → Channels → your website**). Changes there reach the app the next time the chat opens, with no code change: - **Header**: the subtitle under the site name (in the current language), and whether it shows the online agents' photos or the business logo (or the site name's first letter when there is no logo). - **Greeting**: on or off, and whether it shows on phones (an app always counts as a phone). When the sender is set to the online agent, that agent's name appears under the greeting while the team is online. The teaser bubble over the launcher exists only in the web widget. - **Contact buttons**: Zalo, Messenger, phone and email appear as a row of pills under the header and open the matching app on the device. Override the "Call us" and "Email" labels with the `call` and `emailUs` strings. With an older gateway that does not send these settings, the screen keeps its previous behavior: the greeting always shows, the header shows the agents, and there are no contact buttons. ## Languages {#languages} Vietnamese and English are built in. The SDK picks the language in this order: `locale` in `configure`, the visitor's stored choice, the site's default language, then the device language. Change it at runtime: ```swift await HatangChat.setLocale(.en) ``` `HatangChatStrings` is a struct with one property per string. Start from the built-in set and change what you need: ```swift var vi = HatangChatStrings.vi vi.placeholder = "Bạn cần hỗ trợ gì?" vi.team = "Mộc Hương" var en = HatangChatStrings.en en.placeholder = "How can we help?" HatangChat.configure(siteId: "site_…", appKey: "hak_…", strings: [.vi: vi, .en: en]) ``` Strings you do not change keep their built-in text. ## Troubleshooting {#troubleshooting} | Symptom | Cause and fix | |---|---| | `HatangError(invalid_app_key 401)` at startup | The `appKey` is wrong or belongs to another site. Check the **Apps** tab of the right website; keys start with `hak_`. | | `HatangError(app_key_revoked 403)` | The key was revoked in the dashboard. Ship an update with a new key. `error.isAppKeyError` lets you detect both key errors. | | `model.connection` is `.outdated`, event `.protocolOutdated` | The gateway closed the socket with code 4004: this SDK version is too old for the protocol. The SDK stops reconnecting; update `HatangChat` and ship a release. | | `rate_limit_exceeded` | 5 messages per 10 seconds and 60 per minute per visitor; 30 bootstraps per minute per install. `error.isTransient` and `error.isRateLimit` tell you to retry later. | | `http://` calls fail while developing | App Transport Security blocks plain HTTP. Add an `NSAppTransportSecurity` exception (for example `NSAllowsLocalNetworking`) to debug builds only; production always uses `https://chat.hatang.dev`. | | The simulator works with a local gateway but a phone does not | `localhost` on the phone is the phone. Set the gateway's `CHAT_URL` to your Mac's LAN address and use that address as `baseURL`. | | No push notifications | The app registered an APNs token instead of an FCM token (see [Push notifications](#push-notifications)), the app was in the foreground or showing the conversation, or the website's **Apps** tab has no Firebase file. Use **Send a test** there to check the most recent device, and read the "Last error" field. | | The last push error is APNs `TopicDisallowed` | The App ID on Apple Developer does not exactly match the bundle ID, or the APNs key belongs to another team. See [Push notifications → iOS apps](https://hatang.dev/docs/guides/sdks/push-notifications.md#ios-apps). | | Tapping a notification does not open the chat | `handleNotification(_:)` is not called from `userNotificationCenter(_:didReceive:)`, or `UNUserNotificationCenter.current().delegate` is not set. | Pass `logger: .console` to `configure` to see socket frames and HTTP calls in the Xcode console. ## Changelog {#changelog} **0.4.1** (2026-11-03): the package ships a LICENSE file (MIT) and is submitted to the Swift Package Index; the README opens with what the SDK is and ends with an About Ha Tang section. No code changes. **0.4.0** (2026-10-31): in-app purchases. `HatangChat.purchaseToken(userId:)` gives the StoreKit `.appAccountToken`, so App Store Server Notifications reach the right customer; `HatangChat.reportPurchase(...)` after a purchase, with the StoreKit 2 `signedTransaction`: Ha Tang verifies it and shows the plan or purchase on the customer. Product cards carry `basePlanId`, `offerId`, `billingPeriod`, `trialDays` and the App Store price set for the product. **0.3.0** (2026-10-14): the chat follows more of the site's widget settings with no code change: the header subtitle, the business logo instead of agent faces (or the site name's first letter), agent photos in the header, contact buttons (Zalo, Messenger, phone, email) that open the matching app, and the greeting's on/off and on-phones switches. A greeting signed by an online agent shows that agent's name while the team is online. Adds `SiteConfig.header`, `SiteConfig.greetingOptions`, `SiteConfig.contactButtons`, `SiteConfig.showsGreetingInApp` and `SiteConfig.greetingSigner(online:)` in `HatangChatCore`, and the `call` and `emailUs` strings (two new trailing arguments of the memberwise `HatangChatStrings.init`). **0.2.0** (2026-10-13): a payment card switches to "Paid" as soon as the timeline holds the order's `order_paid` message or event; the VietQR code is drawn natively with CoreImage from the EMVCo payload (`qr_payload`, or the public checkout for older cards) because the gateway serves `qr_image_url` as SVG, with a retry button on failure; product, order and payment cards no longer repeat their plain-text summary above the card, only the intro line. Adds `PaymentCard.qrPayload`, `ProductCard.intro`, `PaymentQR`, `cardBubbleText(_:card:)` and `PaidPayments` in `HatangChatCore`. **0.1.0** (2026-10-03): first release: SwiftUI chat view, inbox and launcher (plus a UIKit view controller), photo and file attachments, signed identity, push helpers, Vietnamese and English, light and dark themes. `HatangChatCore` passes the C01–C12 conformance suite. The full history is in the [CHANGELOG](https://github.com/sota-labs/hatang-chat-ios/blob/main/CHANGELOG.md). See also: [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md), [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md), [Compatibility](https://hatang.dev/docs/guides/sdks/compatibility.md) for protocol versions and the minimum gateway, and the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md). Source: https://hatang.dev/docs/guides/sdks/ios --- # MCP server (`@hatang/mcp`) `@hatang/mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server for Ha Tang. Add it to your coding agent and the agent can search these docs and read any page as Markdown while it writes your integration, and call the API for you: send a test email, check a domain's DNS records, create a VietQR payment and settle it in development, read conversations from the inbox or add knowledge for the AI assistant. > **Info: Version** > > `@hatang/mcp` **0.1.1** · Node 20+ · stdio · [npm](https://www.npmjs.com/package/@hatang/mcp) · changelog: `CHANGELOG.md` in the package ## Install {#install} The docs tools need nothing else. For the API tools, create a key in the dashboard under **Settings → API Keys** with only the scopes the agent needs (see [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md)), and keep Payments on `development` while you build. **Claude Code** ```bash claude mcp add hatang --env HATANG_API_KEY=ht_... --env HATANG_ENVIRONMENT=development -- npx -y @hatang/mcp ``` **Cursor** (`.cursor/mcp.json`), **Windsurf** (`~/.codeium/windsurf/mcp_config.json`), **Claude Desktop** and other clients with a JSON config ```json { "mcpServers": { "hatang": { "command": "npx", "args": ["-y", "@hatang/mcp"], "env": { "HATANG_API_KEY": "ht_...", "HATANG_ENVIRONMENT": "development" } } } } ``` **VS Code** (`.vscode/mcp.json`), which asks for the key instead of storing it in the file ```json { "servers": { "hatang": { "command": "npx", "args": ["-y", "@hatang/mcp"], "env": { "HATANG_API_KEY": "${input:hatang-key}", "HATANG_ENVIRONMENT": "development" } } }, "inputs": [{ "id": "hatang-key", "type": "promptString", "description": "Ha Tang API key", "password": true }] } ``` | Variable or flag | | |---|---| | `HATANG_API_KEY` | Your API key. Without it only `search_docs` and `read_doc` work. | | `HATANG_ENVIRONMENT` | Payments environment: `development` (no money moves, `simulate_transfer` works) or `production`. See [Environments](https://hatang.dev/docs/guides/payments/environments.md). | | `HATANG_BASE_URL` | Default `https://api.hatang.dev`. | | `--read-only` | Register only the tools that read. Nothing is sent, created or changed. | ## Tools | Tool | What it does | Changes data | |---|---|---| | `search_docs` | Searches these docs (English or Vietnamese) and returns titles, summaries and Markdown URLs | No | | `read_doc` | Reads one docs page as Markdown | No | | `send_email` | Sends an email (`POST /emails`), with html/text or a published template, and an optional idempotency key | Yes | | `get_email`, `list_emails` | Sent emails and their status | No | | `list_templates` | Email templates | No | | `list_domains`, `get_domain` | Sending domains and the DNS records to publish | No | | `create_domain`, `verify_domain` | Adds a domain, checks its DNS records | Yes | | `create_payment` | Creates a VietQR bank-transfer payment | Yes | | `get_payment`, `list_payments` | Payments and their status | No | | `simulate_transfer` | Development only: pretends the payer's transfer arrived, so IPN and webhooks fire | Yes | | `list_einvoices`, `get_einvoice` | E-invoices | No | | `list_conversations`, `get_conversation` | The support inbox, every channel, with the latest messages | No | | `send_message` | Replies in a conversation, or adds a private note | Yes | | `list_customers` | Customers, searchable by name, email or phone | No | | `knowledge_search` | Searches what the AI assistant answers from | No | | `knowledge_upsert` | Creates or replaces a knowledge document | Yes | Tools that change data carry MCP annotations (`readOnlyHint: false`), so clients ask you before they run them. Results are the API's JSON; errors come back as `name (status): message`, the same as the [error format](https://hatang.dev/docs/guides/getting-started/errors.md). ## Things to ask your agent - "Add order confirmation emails to this app with Ha Tang. Read the docs first." - "Create a VietQR payment for 150,000 VND, simulate the transfer, and show me the IPN my endpoint should verify." - "Is mail.example.com verified? Which DNS records are missing?" - "Summarize the unanswered conversations from today." - "Turn docs/faq.md into knowledge for the AI assistant." ## Safety - The key gives the agent what the key's scopes allow. Use a key for this purpose, restrict it to the scopes you need, and revoke it when you are done. - Keep `HATANG_ENVIRONMENT=development` unless you mean the agent to touch real payments. - `--read-only` is a good default for agents that only need to look things up. - The server never prints the key; it runs on your machine and talks to `api.hatang.dev` and `hatang.dev/docs` only. ## Without MCP Agents that cannot run MCP servers can still read the docs: [`/docs/llms.txt`](https://hatang.dev/docs/llms.txt) lists every page, [`/docs/llms-full.txt`](https://hatang.dev/docs/llms-full.txt) is the whole hub in one file, and every page has a Markdown copy at its URL plus `.md`. For rules files and prompts, see [Use Ha Tang with AI coding agents](https://hatang.dev/docs/guides/getting-started/ai-agents.md). Source: https://hatang.dev/docs/guides/sdks/mcp --- # Node.js SDK (`@hatang/sdk`) `@hatang/sdk` is the server-side client for the Ha Tang API. One client covers email (sending, receiving, domains, audiences, broadcasts), the support inbox (`chat`), payments and e-invoices, API keys and webhooks. It also ships helpers that verify webhook and IPN signatures. Every method returns `{ data, error }` and never throws for an API error. > **Info: Version** > > `@hatang/sdk` **0.6.2** · Node 20+ · Bun 1.1+ · edge runtimes with `fetch` · [npm](https://www.npmjs.com/package/@hatang/sdk) · [API reference (TypeDoc)](https://hatang.dev/docs/reference/js/index.html) · changelog: `CHANGELOG.md` in the package ## Before you start - An API key (`ht_...`) with the scopes your code needs. Create it in the dashboard; see [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md) for scopes and environments. - Node.js 20 or later, Bun 1.1 or later, or a runtime with `fetch` and Web Crypto (Deno, Cloudflare Workers, Vercel Edge). - Keep the key on your server. Never ship it in a browser bundle or a mobile app. ## Install ```bash npm install @hatang/sdk # or pnpm add @hatang/sdk # or bun add @hatang/sdk ``` The package is ESM only (`"type": "module"`). It has one dependency, `@react-email/render`, used only when you send a React element as an email body; `react` is an optional peer dependency. ## Create a client {#client} ```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", }); ``` > **Note: Older versions called localhost** > > Since 0.4.0 the client calls `https://api.hatang.dev` unless you pass `baseUrl` or set `HATANG_BASE_URL` (use either for a local or staging API). Versions up to 0.3.x defaulted to `http://localhost:3001`; on those, set `baseUrl` in production or upgrade. | Option | Default | What it does | |---|---|---| | first argument (API key) | `HATANG_API_KEY`, then `EMAIL_API_KEY` | Sent as `Authorization: Bearer ht_...`. The constructor throws `Missing API key` when none is found. | | `baseUrl` | `HATANG_BASE_URL`, then `EMAIL_API_URL`, then `https://api.hatang.dev` (`http://localhost:3001` before 0.4.0) | API origin. A trailing slash is removed. | | `environment` | `HATANG_ENVIRONMENT` | Payments only: `"development"` or `"production"`, sent as the `Hatang-Environment` header. Without it, a `ht_test_` key uses development and any other key uses production. | | `fetch` | global `fetch` | Your own `fetch` implementation (tests, proxies, instrumentation). | | `userAgent` | `hatang-sdk-node/ (node/)` | The `User-Agent` header. | The client holds no connection state, so you can create one per process and reuse it everywhere. `hatang.baseUrl` tells you which origin it is calling. ## Results and errors {#errors} Every method resolves to a discriminated union: ```ts type Result = { data: T; error: null } | { data: null; error: { statusCode: number; name: string; message: string } }; ``` Check `error` first; TypeScript then knows `data` is set. ```ts const { data, error } = await hatang.emails.get("em_123..."); if (error) { console.error(error.statusCode, error.name, error.message); return; } console.log(data.status); ``` | `error.name` | `statusCode` | Typical cause | |---|---|---| | `missing_api_key` | 401 | No `Authorization` header reached the API | | `invalid_api_key` | 403 | The key is wrong, revoked or from another environment | | `restricted_api_key` | 401 | The key lacks the scope this call needs | | `validation_error`, `missing_required_field` | 422 | A field is missing or has the wrong shape | | `not_found` | 404 | The id does not exist in this business | | `invalid_idempotent_request`, `concurrent_idempotent_requests` | 400, 409 | The same `Idempotency-Key` was reused with a different body, or is still in flight | | `rate_limit_exceeded`, `daily_quota_exceeded` | 429 | Too many requests; wait and retry | | `application_error` | 0 | The API could not be reached (DNS, TLS, timeout). `message` starts with `Unable to reach the API` | The full list is in [Errors](https://hatang.dev/docs/reference/errors.md). Network failures are returned as `application_error` with `statusCode: 0`, not thrown. The SDK never retries on its own, so you decide how to retry (a queue, a cron job, a backoff loop). Rate limits per product are listed in [Limits](https://hatang.dev/docs/reference/limits.md). Only three things throw: the constructor without an API key, sending a `react` body when `react` is not installed, and the `construct*` signature helpers when a signature does not match (they throw `HatangSignatureError`). ## Emails {#emails} ```ts const { data, error } = await hatang.emails.send( { from: "Shop ", to: ["customer@example.com"], subject: "Order SO-1001 confirmed", html: "

Thanks for your order.

", text: "Thanks for your order.", tags: [{ name: "type", value: "order" }], }, { idempotencyKey: "order-SO-1001-confirmation" }, ); // data → { id: "..." } ``` - `from` must use a verified domain ([Domains](#domains)). - `to`, `cc`, `bcc` and `reply_to` take a string or an array. - `headers`, `tags`, `scheduled_at` (ISO 8601) and `attachments` are optional. An attachment's `content` can be a base64 string, a `Buffer` or a `Uint8Array`; the SDK encodes binary content to base64 for you. - `idempotencyKey` is sent as `Idempotency-Key`, so a retried call sends the email once. Send a React Email component instead of HTML. The SDK renders it to `html`, and to `text` when you did not pass one: ```tsx import { WelcomeEmail } from "./emails/welcome"; await hatang.emails.send({ from: "Shop ", to: "customer@example.com", subject: "Welcome", react: }); ``` Other email calls: ```ts await hatang.emails.get("em_123..."); await hatang.emails.list({ limit: 50 }); await hatang.emails.update({ id: "em_123...", scheduledAt: "2026-11-01T09:00:00+07:00" }); // reschedule await hatang.emails.cancel("em_123..."); // cancel a scheduled send ``` Mail received on a domain with receiving turned on (see [Receiving email](https://hatang.dev/docs/guides/email/receiving.md)): ```ts const { data: inbox } = await hatang.emails.receiving.list({ limit: 20 }); const { data: mail } = await hatang.emails.receiving.get(inbox.data[0].id); const { data: files } = await hatang.emails.receiving.attachments.list(mail.id); // files.data[0].download_url works without an API key for one hour ``` ## Batch {#batch} Send 1 to 100 emails in one request. Each item has the same shape as `emails.send`. ```ts const { data } = await hatang.batch.send([ { from: "Shop ", to: "a@example.com", subject: "Hi A", html: "

Hi A

" }, { from: "Shop ", to: "b@example.com", subject: "Hi B", html: "

Hi B

" }, ]); // data.data → [{ id }, { id }] ``` ## Domains {#domains} ```ts const { data: domain } = await hatang.domains.create({ name: "shop.vn" }); // domain.records → DNS records to add at your DNS host await hatang.domains.verify(domain.id); await hatang.domains.update({ id: domain.id, open_tracking: true, click_tracking: true, tls: "enforced" }); await hatang.domains.update({ id: domain.id, capabilities: { receiving: "enabled" } }); const { data: all } = await hatang.domains.list(); await hatang.domains.remove(domain.id); ``` See [Email overview](https://hatang.dev/docs/guides/email/overview.md) for the DNS records and verification flow. ## Audiences and contacts {#contacts} Since 0.4.0, contacts belong to the business, not to one audience. Address a contact by `id` or `email`, and group contacts with segments and topics. ```ts await hatang.contacts.create({ email: "lan@example.com", first_name: "Lan", properties: { plan: "pro" } }); await hatang.contacts.update({ email: "lan@example.com", unsubscribed: true }); const { data: contact } = await hatang.contacts.get("lan@example.com"); const { data: list } = await hatang.contacts.list({ segment_id: "aud_123..." }); await hatang.contacts.segments.add("lan@example.com", "aud_123..."); await hatang.contacts.topics.update("lan@example.com", [{ id: "topic_123...", subscription: "opt_out" }]); await hatang.contacts.remove(contact.id); ``` Passing `audience_id` still works and uses the older audience routes: ```ts const { data: audience } = await hatang.audiences.create({ name: "Newsletter" }); await hatang.contacts.create({ audience_id: audience.id, email: "lan@example.com" }); ``` `audiences.get`, `audiences.list` and `audiences.remove` complete the set. Contacts can be addressed by `id` or `email`. See [Audiences](https://hatang.dev/docs/guides/email/audiences.md). ## Broadcasts {#broadcasts} ```ts const { data: draft } = await hatang.broadcasts.create({ audience_id: "aud_123...", from: "Shop ", subject: "New autumn tea", html: "

Hi {{{contact.first_name}}}, our autumn tea is here.

", name: "Autumn launch", }); await hatang.broadcasts.send(draft.id); // now await hatang.broadcasts.send(draft.id, { scheduledAt: "2026-11-01T08:00:00+07:00" }); // or later await hatang.broadcasts.cancel(draft.id); ``` `get`, `list`, `update` and `remove` work as their names say. ## Templates {#templates} ```ts const { data: tpl } = await hatang.templates.create({ name: "Order confirmed", alias: "order-confirmed", subject: "Order {{{ORDER}}} confirmed", html: "

Thanks! Your order {{{ORDER}}} is confirmed.

", variables: [{ key: "ORDER", type: "string" }], }); await hatang.templates.publish(tpl.id); await hatang.emails.send({ from: "Shop ", to: "customer@example.com", template: { id: "order-confirmed", variables: { ORDER: "SO-1001" } }, }); ``` `get`, `list`, `update`, `remove` and `duplicate` take an id or an alias. With a template, `from` and `subject` can come from the template. See [Templates](https://hatang.dev/docs/guides/email/templates.md). ## API keys {#api-keys} ```ts const { data: key } = await hatang.apiKeys.create({ name: "Billing worker", scopes: ["payments:write"] }); // key.token is shown once: store it now const { data: keys } = await hatang.apiKeys.list(); await hatang.apiKeys.remove(key.id); ``` ## Webhooks {#webhooks} Create an endpoint and keep its signing secret: ```ts const { data: hook } = await hatang.webhooks.create({ endpoint: "https://shop.vn/api/hatang/webhooks", events: ["email.delivered", "email.bounced", "chat.message.created", "payment.succeeded"], }); // hook.signing_secret → "whsec_..." ``` `get`, `list`, `update` (`endpoint`, `events`, `status`, `description`), `remove`, `rotateSecret` and `deliveries` manage it afterwards. Verify each delivery before you trust it. Pass the raw body exactly as received, before any JSON parsing: ```ts title="app/api/hatang/webhooks/route.ts (Next.js)" import { constructWebhookEvent, HatangSignatureError } from "@hatang/sdk"; export async function POST(request: Request) { const rawBody = await request.text(); try { const event = await constructWebhookEvent(rawBody, request.headers, process.env.HATANG_WEBHOOK_SECRET!); if (event.type === "chat.message.created") await notifySales(event.data); return new Response(null, { status: 204 }); } catch (err) { if (err instanceof HatangSignatureError) return new Response("bad signature", { status: 400 }); throw err; } } ``` | Helper | Returns | Notes | |---|---|---| | `verifyWebhook(rawBody, headers, secret, { toleranceSec? })` | `Promise` | Checks `svix-id` / `svix-timestamp` / `svix-signature` (or the `webhook-*` aliases). Refuses deliveries older than `toleranceSec`, 300 seconds by default. | | `constructWebhookEvent(rawBody, headers, secret, { toleranceSec? })` | `Promise` | Verifies, then returns `{ type, created_at, data }`. Throws `HatangSignatureError` on a bad signature or an old timestamp. | `headers` can be a Fetch `Headers` object or a plain object such as Express's `req.headers`. With Express, read the body with `express.raw({ type: "application/json" })` so it stays unparsed. Event names and payloads are in [Webhooks](https://hatang.dev/docs/reference/webhooks.md); retries and setup are in the [Webhooks guide](https://hatang.dev/docs/guides/getting-started/webhooks.md). ## Support inbox (`chat`) {#chat} The `chat` namespace wraps the support inbox API: conversations and messages from the website widget, mobile apps and every connected channel, plus customers, products, orders, tickets and reports. Use it to connect the inbox to your ERP, CRM or your own bot. Keys need `chat:read` to read and `chat:write` to reply and change things. Products and orders also accept `products:read` / `products:write`, and customers and ZNS accept `crm:read` / `crm:write`. `chat:reply` and `chat:manage` are permissions of employees in the dashboard, not API key scopes. ### Conversations and messages ```ts // Open conversations where the customer spoke last const { data: open } = await hatang.chat.conversations.list({ status: "open", unanswered: true }); // Reply as an agent. The idempotency key makes a retry safe. await hatang.chat.conversations.send(open.data[0].id, { content: "Yes, it's in stock." }, { idempotencyKey: "reply-42" }); // Internal note the customer never sees await hatang.chat.conversations.send(open.data[0].id, { content: "VIP customer", visibility: "private" }); // Assign, tag, resolve await hatang.chat.conversations.update(open.data[0].id, { assignee_id: "usr_...", tags: ["vip"], status: "resolved" }); // Timeline (messages and events) after a sequence number, for incremental sync const { data: page } = await hatang.chat.conversations.messages("cnv_...", { after_seq: 120, limit: 200 }); for (const item of page.data) if (item.kind === "message") console.log(item.sender_type, item.content); ``` `list` filters by `status` (one or several of `open`, `pending`, `resolved`, `closed`), `assignee_id`, `site_id`, `visitor_id`, `q`, `unanswered` and `flag`, and pages with `limit` and `after`. `seq` grows by one per item inside a conversation; it is the same `seq` the widget and the mobile SDKs use to resume (see [Realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md)). `send` returns `{ id, seq, conversation_id, duplicate }`; `duplicate: true` means the idempotency key matched an earlier call. ### Customers ```ts const { data: customers } = await hatang.chat.customers.list({ stage: "qualified", channel: "instagram" }); await hatang.chat.customers.update("cus_...", { stage: "customer", labels: ["wholesale"], fields: { customer_code: "KH0012" } }); await hatang.chat.customers.merge("cus_a", ["cus_b"]); // fold duplicates into cus_a const { data: csv } = await hatang.chat.customers.exportCsv({ label: "wholesale" }); // CSV text ``` Visitors identified with a signed `identify` from the widget or a mobile SDK appear with `external_id` set to your user id ([Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md)). `customers.fields` manages custom fields; `chat.visitors` reads and edits a single visitor. ### Orders and payment links ```ts const { data: order } = await hatang.chat.orders.create({ conversation_id: "cnv_...", items: [{ name: "Lotus tea 100g", price: 120_000, quantity: 2 }], shipping: 30_000, send_card: true, // post an order card into the conversation }); const { data: link } = await hatang.chat.orders.paymentLink(order.id, { expires_in: 3600 }); // link.checkout_url, link.qr_image_url (SVG), link.qr_payload (EMVCo string) // When the customer pays, the order becomes paid on its own. const { data: paid } = await hatang.chat.orders.list({ status: "confirmed", payment_status: "paid" }); ``` Amounts are integers in VND. Orders mirrored from TikTok Shop sit in the same list with `source: "tiktokshop"`, the shop's `external_id` and `external_status`. ### Products, tickets, live visitors and reports ```ts await hatang.chat.products.import({ url: "https://docs.google.com/spreadsheets/d/.../pub?output=csv" }); await hatang.chat.conversations.sendProducts("cnv_...", ["prod_a", "prod_b"], "Here are two you might like"); await hatang.chat.tickets.create({ title: "Size exchange", priority: "high", conversation_id: "cnv_..." }); const { data: overdue } = await hatang.chat.tickets.list({ open: true, overdue: true }); const { data: live } = await hatang.chat.live.list(); await hatang.chat.live.greet(live.data[0].id, "Hi! Can I help you find something?"); const { data: report } = await hatang.chat.reports.get({ from: "2026-09-01", to: "2026-09-30" }); ``` The namespace also has `sites` (widget sites), `cannedResponses`, `zns` (Zalo ZNS through a connected Official Account) and `reports.ratings`. See [Support REST API](https://hatang.dev/docs/guides/support/rest-api.md) for what each endpoint does. ### Knowledge, subscriptions and profile links ```ts // What the AI assistant answers from, under your own ids; unchanged documents are not re-indexed. await hatang.knowledge.sync("help", articles.map((a) => ({ external_id: `help:${a.slug}`, title: a.title, content: a.body, url: a.url }))); const { data: hits } = await hatang.knowledge.search("đổi trả trong bao lâu", { locale: "vi" }); // Plans your backend knows about (web, App Store, Google Play), shown on the customer and to the assistant. await hatang.chat.subscriptions.upsert("web", "sub_123", { external_user_id: "u_42", sku: "PRO-1M", status: "active", current_period_end: "2026-11-30T00:00:00Z" }); // One-time purchases (credit packs, unlocks, courses), web sales included; status "refunded" later for the same id. await hatang.chat.purchases.upsert("web", "order_981", { external_user_id: "u_42", store_product_id: "credits.300", sku: "XU-300", kind: "consumable", amount: 29000, currency: "VND" }); const { data: token } = await hatang.chat.subscriptions.purchaseToken("site_...", "u_42"); // StoreKit appAccountToken / Play obfuscatedAccountId // Links agents open for the person in a conversation. await hatang.chat.profileLinks.set([{ label: "Admin", url: "https://example.com/admin/users/{{user_id}}" }]); ``` See [Knowledge API](https://hatang.dev/docs/guides/support/knowledge-api.md) for documents and collections. ## Payments and e-invoices {#payments} ```ts const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev", environment: "development" }); // drop environment in production // One payment per order: the order id is the idempotency key, so retrying returns the same payment const { data: payment } = await hatang.payments.createOrder({ order_id: "SO-1001", amount: 150_000, ipn_url: "https://shop.vn/api/hatang/ipn", return_url: "https://shop.vn/orders/SO-1001", }); // payment.checkout_url, payment.qr.image_url, payment.code // Before you ship: ask Hatang whether it is really paid for this order const { data: check } = await hatang.payments.verify(payment.id, { order_id: "SO-1001", amount: 150_000 }); if (check?.verified) await fulfil("SO-1001"); ``` Verify each IPN call on your `ipn_url`: ```ts import { constructIpnEvent } from "@hatang/sdk"; const ipn = await constructIpnEvent(rawBody, request.headers, process.env.HATANG_SECRET_KEY!); // throws HatangSignatureError if (ipn.data.status === "paid") await markOrderPaid(ipn.data.metadata.order_id); ``` | Helper | Returns | Notes | |---|---|---| | `verifyIpn(rawBody, signature, secretKey)` | `Promise` | HMAC-SHA256 (hex) of the raw body with the secret key, compared with the `Hatang-Signature` header. | | `constructIpnEvent(rawBody, headers, secretKey)` | `Promise` | Reads `Hatang-Signature` from `headers`, verifies, returns `{ id, event_id, type, environment, created_at, data }`. Dedupe on `id`: it stays the same on every retry. | `payments` also has `create`, `get`, `list`, `cancel`, `markPaid`, `ipnDeliveries`, `qr`, `simulateTransfer` (development only), `transactions`, `bankConnections` and `statements`. `einvoices` creates, publishes, replaces and syncs e-invoices through your provider account. The full walkthrough is [Payments → JavaScript SDK](https://hatang.dev/docs/guides/payments/sdk-javascript.md). ## Identity hash for the chat widget and SDKs {#identity-hash} Your server is the only place that holds the site's identity secret. The SDK has no helper for it because it is one line of `crypto`: ```ts import { createHmac } from "node:crypto"; const hash = createHmac("sha256", process.env.HATANG_IDENTITY_SECRET!).update(userId).digest("hex"); // Return { userId, hash } to the widget or the mobile app ``` See [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md). ## TypeScript types {#types} The package is written in TypeScript and exports every request and response type, so you rarely need to write your own. ```ts import type { CreateEmailOptions, Email, Payment, WebhookEvent, ChatConversation, ChatMessage, Result } from "@hatang/sdk"; import { VERSION } from "@hatang/sdk"; // "0.6.2" ``` Chat types follow the API's snake_case field names. `constructWebhookEvent()` takes a type parameter for `data`, for example `constructWebhookEvent(…)`. ## Runtimes {#runtimes} | Runtime | Status | |---|---| | Node.js 20+ | Supported | | Bun 1.1+ | Supported | | Deno, Cloudflare Workers, Vercel Edge | The client works where `fetch` exists; set `baseUrl` explicitly. The `User-Agent` then reports `node/unknown`. | `verifyWebhook`, `constructWebhookEvent`, `verifyIpn` and `constructIpnEvent` use Web Crypto only, so they run in every runtime above. Sending attachments as `Buffer` needs Node's `Buffer`; on edge runtimes pass base64 strings instead. ## New in 0.6.0 {#whats-new} | Change | Details | |---|---| | `chat.purchases` | `upsert(platform, transactionId, payload)`, `list`, `forCustomer`: one-time purchases from your backend, web sales and refunds included | | `sku` | on `chat.subscriptions.upsert` and `chat.purchases.upsert`: finds the catalog product by SKU, for sales on your own site | | CommonJS | `require("@hatang/sdk")` works in CommonJS backends (NestJS, older Express apps, ts-jest), with its own type declarations | ## New in 0.5.0 {#new-in-0-5} | Namespace | Calls | |---|---| | `knowledge` | `upsert`, `batch`, `sync`, `get`, `list`, `remove`, `search` | | `chat.subscriptions` | `upsert`, `list`, `forCustomer`, `purchaseToken` | | `chat.profileLinks` | `list`, `set` | | `chat.products` | software products: `kind`, `billing_period`, `trial_days` and per-platform `prices` | ## New in 0.4.0 {#new-in-0-4} 0.4.0 adds these calls (see the [changelog](https://www.npmjs.com/package/@hatang/sdk?activeTab=versions) for details): | Namespace | Calls | |---|---| | `templates` | `create`, `get`, `list`, `update`, `remove`, `publish`, `duplicate`, and `emails.send({ template })` | | `emails` | `metrics()` | | `logs`, `usage` | `logs.list`, `logs.get`, `usage.get` | | `contacts` | global contacts without `audience_id`, `contacts.segments.*`, `contacts.topics.*` | | `segments`, `topics`, `contactProperties` | full CRUD | | `events`, `automations` | `events.create/get/list/update/remove/send`, `automations.create/get/list/update/remove/stop/duplicate`, `automations.runs.list/get` | 0.4.0 also drops the `blog` namespace, which is no longer part of the public API, and makes `https://api.hatang.dev` the default `baseUrl`. ## Next steps - [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md): API keys, scopes and environments. - [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md): set up an endpoint and handle retries. - [Payments → JavaScript SDK](https://hatang.dev/docs/guides/payments/sdk-javascript.md): the full payment flow. - [API reference](https://hatang.dev/docs/reference/api.md): every endpoint the SDK wraps. Source: https://hatang.dev/docs/guides/sdks/node --- # Ha Tang SDKs Ha Tang's SDKs put the support chat inside your website and apps, and connect your servers to the Ha Tang API. Every chat client speaks the same [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md) with the gateway at `chat.hatang.dev`, so a customer who writes from your website, your mobile app, Zalo, Messenger, Instagram or TikTok Shop lands in the same inbox. ## The SDKs {#sdks} | Platform | Package | Status | Guide | |---|---|---|---| | Website (script tag) | `https://chat.hatang.dev/widget.js` | Stable | [Web widget and JS API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md) | | React / Next.js | [`@hatang/chat-react`](https://www.npmjs.com/package/@hatang/chat-react) | Stable | [React and Next.js](https://hatang.dev/docs/guides/sdks/react.md) | | WordPress | [Ha Tang Chat](https://wordpress.org/plugins/hatang-chat/) | Stable | [WordPress plugin](https://hatang.dev/docs/guides/integrations/wordpress.md) | | Flutter (iOS, Android, web, desktop) | [`hatang_chat`](https://pub.dev/packages/hatang_chat) | Stable | [Flutter](https://hatang.dev/docs/guides/sdks/flutter.md) | | React Native / Expo | [`@hatang/chat-react-native`](https://www.npmjs.com/package/@hatang/chat-react-native) | Stable | [React Native](https://hatang.dev/docs/guides/sdks/react-native.md) | | iOS (Swift) | [`HatangChat`](https://github.com/sota-labs/hatang-chat-ios) | Stable | [iOS](https://hatang.dev/docs/guides/sdks/ios.md) | | Android (Kotlin) | [`dev.hatang:chat`](https://central.sonatype.com/artifact/dev.hatang/chat) | Stable | [Android](https://hatang.dev/docs/guides/sdks/android.md) | | Node.js (server) | [`@hatang/sdk`](https://www.npmjs.com/package/@hatang/sdk) | Stable | [Node.js SDK](https://hatang.dev/docs/guides/sdks/node.md) | | Python (server) | [`hatang`](https://pypi.org/project/hatang/) | Stable | [Python SDK](https://hatang.dev/docs/guides/sdks/python.md) | | AI coding agents (MCP) | [`@hatang/mcp`](https://www.npmjs.com/package/@hatang/mcp) | New | [MCP server](https://hatang.dev/docs/guides/sdks/mcp.md) | ## How they fit together {#how-they-fit} **On the web, the widget is the core.** `@hatang/chat-react` loads the widget in a React app and the WordPress plugin loads it in a WordPress site. Both drive the same [`$hatang` JavaScript API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md), and the widget follows the settings you make in the dashboard. **In mobile apps, the SDKs are native.** The Flutter, React Native, iOS and Android SDKs do not embed a web view. Each one speaks the protocol to the gateway itself and ships its own chat screen, conversation list, launcher button, push notification helpers and deep links. They share one contract: the same page structure in these docs, the same events, and the same conformance scenarios (C01–C12) that every SDK must pass against a real gateway before a release. An app identifies itself with an app key (`hak_...`) instead of the browser's Origin. **On your server, use `@hatang/sdk` (Node.js) or `hatang` (Python).** They call the REST API with an API key: send email, read and answer conversations as an agent, put plans and purchases on customers, sync customers, orders and the assistant's knowledge, create payments and verify webhooks. For PHP, Go or any other language, call the [REST API](https://hatang.dev/docs/reference/api.md) directly. ## Topics shared by every chat SDK {#shared} - [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md): `identify` with and without a signature, the identity secret, and how the inbox merges sessions. - [Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md): upload a Firebase service account, when Ha Tang sends a push, and the payload. - [Realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md): the WebSocket frames, `seq` and `resume`, close codes, and downloadable JSON Schema. - [Compatibility](https://hatang.dev/docs/guides/sdks/compatibility.md): SDK, protocol and minimum gateway versions, and the versioning policy. ## Support matrix {#support-matrix} | SDK | Status | Current | Supported | Protocol | Platforms | |---|---|---|---|---|---| | widget.js | Stable | — | always current (served by the gateway) | 1 | Browsers with ES2020 (Chrome 80+, Safari 14+, Firefox 78+, Edge 80+) | | @hatang/chat-react | Stable | 0.1.2 | 0.1.x | 1 | React 18+; Next.js 14+ (App Router and Pages) | | @hatang/sdk | Stable | 0.6.2 | 0.6.x | 1 | Node 20+; Bun 1.1+; Edge runtimes with fetch (User-Agent reports node/unknown) | | hatang (Python) | Stable | 0.1.1 | 0.1.x | 1 | Python 3.9+ (sync and asyncio, httpx) | | hatang_chat (Flutter) | Stable | 0.6.1 | 0.6.x | 1 | Flutter 3.32+; Dart 3.8+; iOS 15+; Android 6.0 (API 23)+; macOS 11+; Web; Windows 10+; Linux (secure storage falls back to preferences) | | @hatang/chat-react-native | Stable | 0.4.2 | 0.4.x | 1 | React Native 0.76+; Expo SDK 52+ | | iOS (Swift) | Stable | 0.4.1 | 0.4.x | 1 | iOS 17+; Swift 6 / Xcode 16+; SwiftUI and UIKit (HatangChatViewController) | | Android (Kotlin) | Stable | 0.4.1 | 0.4.x | 1 | Android 8+ (API 26); Kotlin 1.9+ / 2.x; Jetpack Compose (BOM 2024.12+); View-based apps open the chat through HatangChat.open | | WordPress plugin | Stable | 0.2.3 | 0.2.x | 1 | WordPress 6.0+; PHP 7.4+ | The full table, with packages and minimum gateway versions, is on [Compatibility](https://hatang.dev/docs/guides/sdks/compatibility.md). ## API references {#api-references} - Ha Tang REST API (email, inbox, payments): [API reference](https://hatang.dev/docs/reference/api.md). - Chat gateway (the widget and SDK endpoints, with OpenAPI): [Chat gateway reference](https://hatang.dev/docs/reference/chat-gateway.md). The machine-readable protocol is at [chat.hatang.dev/protocol.json](https://chat.hatang.dev/protocol.json). - `@hatang/sdk`: [TypeDoc reference](https://hatang.dev/docs/reference/js/index.html). `@hatang/chat-react`: README on [npm](https://www.npmjs.com/package/@hatang/chat-react). - `hatang_chat` (Flutter): [dartdoc reference](https://hatang.dev/docs/reference/flutter/index.html). - `HatangChat` and `HatangChatCore` (iOS): [DocC reference](https://hatang.dev/docs/reference/ios/documentation). - `dev.hatang:chat` and `dev.hatang:chat-core` (Android): [Dokka reference](https://hatang.dev/docs/reference/android/index.html). The SDK references are generated from the doc comments in each SDK's source and are in English. ## Next steps - Add chat to a website: [Web widget and JS API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md) or [React and Next.js](https://hatang.dev/docs/guides/sdks/react.md). - Add chat to a mobile app: [Flutter](https://hatang.dev/docs/guides/sdks/flutter.md), [React Native](https://hatang.dev/docs/guides/sdks/react-native.md), [iOS](https://hatang.dev/docs/guides/sdks/ios.md) or [Android](https://hatang.dev/docs/guides/sdks/android.md). - Call the API from your server: [Node.js SDK](https://hatang.dev/docs/guides/sdks/node.md). Source: https://hatang.dev/docs/guides/sdks/overview --- # Push notifications for in-app customers When an agent or a bot replies and the customer does not have the chat open, Ha Tang sends a push notification to the customer's phone through **your own Firebase project** (FCM HTTP v1). There is no shared Ha Tang Firebase project: notifications carry your app's name and icon, and they count against your project's quota. This page covers the setup shared by the native SDKs ([Flutter](https://hatang.dev/docs/guides/sdks/flutter.md#push-notifications), [React Native](https://hatang.dev/docs/guides/sdks/react-native.md#push-notifications), [iOS](https://hatang.dev/docs/guides/sdks/ios.md#push-notifications) and [Android](https://hatang.dev/docs/guides/sdks/android.md#push-notifications)). Website visitors get a "we replied" email instead. ## Before you start - A mobile app that uses one of the native SDKs, with an app key (`hak_...`). - A Firebase project with your Android app and/or iOS app added to it. - For iOS: an Apple Developer account that owns your app's bundle ID. ## 1. Upload the Firebase service account {#service-account} 1. Firebase console → **Project settings → Service accounts → Generate new private key**. A JSON file downloads. 2. Ha Tang dashboard → **Support → Channels** → your website → **Apps** tab → **Push notifications (Firebase)** → choose the file → **Save Firebase credentials**. 3. The card then shows the project, the service account, when it was last used and the last error, if Google refused the key. The file is encrypted at rest. Until you upload one, in-app customers only get an email when someone replies, and only if they left an email address. **Remove credentials** deletes it. ### iOS apps {#ios-apps} Firebase delivers to iPhones through APNs, so your Firebase project also needs: 1. An **App ID** in Apple Developer (Certificates, Identifiers & Profiles → Identifiers) whose bundle ID matches your app **exactly**, with **Push Notifications** turned on. You only need the App ID; you do not have to create the app in App Store Connect. 2. An **APNs key** (Keys → +, tick Apple Push Notifications service). Choose the **Sandbox & Production** environment so it works for debug builds, TestFlight and the App Store. Download the `.p8` file and note the Key ID and your Team ID. 3. Firebase → Project settings → **Cloud Messaging → Apple app configuration** → upload the key with its Key ID and Team ID. When the App ID does not match the bundle ID (a trailing dot is enough) or the key belongs to another team, FCM answers every send with the APNs error `TopicDisallowed`. Ha Tang shows it as the last error on the Firebase card. Fixing the App ID needs no new app build. > **Note: Direct APNs** > > Ha Tang sends through Firebase only. The iOS SDK can register a raw APNs device token, and the gateway accepts devices with `provider: "apns"`, but nothing is delivered to them yet. Until direct APNs delivery ships, iOS apps should use Firebase Messaging and register the FCM token. ### Restrict your app's Firebase API key {#api-key} The API key inside your app's Firebase config (`google-services.json`, `GoogleService-Info.plist`) is not a secret: it ships in every build. Restrict it in Google Cloud console → APIs & Services → Credentials → your key → **API restrictions** to the APIs your app calls to get a token, such as the Firebase Installations API and the FCM Registration API. Ha Tang does not use this key; it sends with the service account. ## 2. Register the device from the SDK {#register} Your app gets the FCM token from Firebase and hands it to the SDK: | SDK | Call | |---|---| | Flutter | `HatangChat.instance.setPushToken(token, provider: PushProvider.fcm)` | | React Native | `Hatang.setPushToken(token, "fcm")` | | iOS | `await HatangChat.setPushToken(fcmToken: token)` | | Android | `HatangChat.setPushToken(token)` (a suspend function) | The SDK calls `PUT /widget/v1/sites/{siteId}/devices` with the token, the provider, the platform and, when known, the install id, language, app version, OS version and device model. A token is one phone. When a visitor's identity is [merged](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md), their devices follow the person. `clearPushToken()` unregisters the token (`DELETE devices`); call it on sign-out before `reset()`. The website's **Apps** tab lists the **Registered devices** with the time of their last push. ## 3. When Ha Tang sends a push {#when} For every **public** message from an agent or a bot (not the customer's own messages, and not internal notes), Ha Tang checks, in this order: | Check | If it fails | |---|---| | The conversation is not marked as spam and the visitor still exists | Skip | | The visitor has at least one registered device | Skip the push. The "we replied" email follows its own rules (the visitor is offline and left an email address) | | The visitor is **not** connected with the app in the foreground | Skip: the message arrives over the socket | | The visitor is **not** looking at this very conversation (`app.state` with its `conversation_id`) | Skip | | The site has Firebase credentials | Skip, and log it once an hour per site | | Fewer than 20 pushes in the last minute for this visitor | Skip | Ha Tang then sends to each device. A token FCM reports as unregistered is deleted, a temporary FCM error is retried, and a credentials error (Google refused the key) is shown as the last error on the Firebase card. A visitor with a registered phone does **not** also get an email for each reply; the transcript email when a conversation is resolved still goes out. The SDKs send `app.state` with `foreground: false` as soon as the app goes to the background, so "connected" means the app is really on screen. A client that does not send `app.state` looks online for up to 60 seconds after it goes to the background and misses pushes in that window. ## 4. The payload {#payload} ```json { "notification": { "title": "Lan (Moc Huong Tea)", "body": "Yes, it's in stock." }, "data": { "hatang": "1", "site_id": "site_…", "conversation_id": "cnv_…", "message_id": "msg_…", "visitor_id": "vis_…", "kind": "message" }, "android": { "priority": "high", "collapse_key": "cnv_…", "notification": { "tag": "cnv_…", "channel_id": "hatang_messages" } }, "apns": { "headers": { "apns-priority": "10", "apns-collapse-id": "cnv_…" }, "payload": { "aps": { "sound": "default", "thread-id": "cnv_…", "badge": 3, "mutable-content": 1 } } } } ``` | Field | Meaning | |---|---| | `notification.title` | The sender's name; the site name when the message has none | | `notification.body` | The first 140 characters of the message (cards are turned into text). For a message with only an attachment: "🖼 Photo" or "📎 Attachment", in the visitor's language | | `data.*` | Strings only. `hatang: "1"` lets `isHatangNotification` recognize the push; `kind` is `message`, or `test` for the dashboard's test push | | `collapse_key`, `tag`, `apns-collapse-id`, `thread-id` | The conversation id: several replies in a row replace each other and group by conversation | | `aps.badge` (iOS) | The visitor's unread total across open and pending conversations | | `android.notification.channel_id` | `hatang_messages`. On Android 8+, create a channel with this id in your app to give it a name and an importance; without it, Android uses Firebase's fallback channel | When the customer taps the notification, pass `data` to the SDK's `handleNotification`, which opens the right conversation. Each SDK page shows how to wire the tap for a cold start and for an app in the background. ## 5. Send a test {#test} **Send a test** on the website's **Apps** tab sends a fixed notification ("Test notification from Ha Tang: everything is set up.") to the site's most recently connected device, with `data` `{ site_id, kind: "test", hatang: "1" }` and no conversation. Use it to check the Firebase file, the Android channel and the notification permission before you release. With no registered device, the dashboard says "No device has registered for notifications yet." ## Next steps - [Realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md): the `app.state` frame and the `PUT` / `DELETE devices` endpoints. - [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md): devices follow the person when identities merge. - Wire the tap in your SDK: [Flutter](https://hatang.dev/docs/guides/sdks/flutter.md#push-notifications), [React Native](https://hatang.dev/docs/guides/sdks/react-native.md#push-notifications), [iOS](https://hatang.dev/docs/guides/sdks/ios.md#push-notifications), [Android](https://hatang.dev/docs/guides/sdks/android.md#push-notifications). Source: https://hatang.dev/docs/guides/sdks/push-notifications --- # Python SDK (`hatang`) `hatang` is the server-side client for the Ha Tang API in Python. It covers what a backend does most: put plans and purchases on the customer's profile, sync the AI assistant's knowledge, answer conversations, send email, create VietQR payments, and verify webhooks and IPN calls. There is a sync client (`Hatang`) and an asyncio one (`AsyncHatang`) with the same methods. > **Info: Version** > > `hatang` **0.1.1** · Python 3.9+ · [PyPI](https://pypi.org/project/hatang/) · changelog: `CHANGELOG.md` in the package ## Before you start - An API key (`ht_...`), created in the dashboard (see [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md)), with the scopes you call: `crm:write` for plans and purchases, `chat:write` for conversations and knowledge, `email:send` for email. - For the chat identity hash, the site's identity secret (**Support → Channels → the site → Widget**). ## Install ```bash pip install hatang # or uv add hatang # or poetry add hatang ``` ## Create a client {#client} ```python from hatang import Hatang hatang = Hatang() # reads HATANG_API_KEY (and HATANG_BASE_URL, HATANG_ENVIRONMENT) hatang = Hatang("ht_...", base_url="http://localhost:3001", timeout=10) ``` Close it when you are done (`hatang.close()`, or `with Hatang() as hatang:`), or keep one for the life of the process. Pass your own `httpx.Client` as `http_client` for proxies, retries or tests. asyncio (FastAPI, aiohttp, Starlette): ```python from hatang import AsyncHatang hatang = AsyncHatang() async def on_paid(order): await hatang.chat.purchases.upsert("web", order.id, {...}) ``` ## Results and errors {#errors} Every method returns the API's JSON as a `dict`. A call that does not succeed raises `HatangError`: ```python from hatang import HatangError try: hatang.chat.purchases.upsert("web", order.id, body) except HatangError as e: e.status_code # HTTP status; 0 when Ha Tang was not reached (network, timeout) e.name # validation_error, not_found, rate_limit_exceeded, ... e.retryable # True for unreachable, 429 and 5xx: retry those later ``` This is the Python habit; the Node SDK returns `{ data, error }` instead. Field names are the API's snake_case in both. ## Plans and purchases {#purchases} Put what a customer pays for on their profile, so agents and the AI assistant can answer "when does my plan renew?" from facts. Use the same user id you pass to `identify` in the chat. ```python # A plan sold on your website (or one you verified with the App Store / Google Play yourself). hatang.chat.subscriptions.upsert("web", subscription.id, { "status": "active", "sku": "PRO-1M", # the catalog product's SKU "external_user_id": str(user.id), "started_at": "2026-10-01T00:00:00Z", "current_period_end": "2026-11-01T00:00:00Z", "auto_renew": False, "amount": 99000, "currency": "VND", }) # One-time purchases: credit packs, unlocks, courses. Send status="refunded" for the same id on a refund. hatang.chat.purchases.upsert("web", order.id, { "store_product_id": "credits:300", # your own code on the web; the store's product id in an app "sku": "XU-300", "kind": "consumable", "external_user_id": str(user.id), "amount": 29000, "currency": "VND", "purchased_at": "2026-10-30T10:00:00Z", }) hatang.chat.purchases.list(external_user_id=str(user.id)) ``` Store purchases use platform `ios` (Apple originalTransactionId for plans, transactionId for one-time purchases) or `android` (Play purchase token, or order id). Sending the same record again only updates it, so a backend can re-send everything it knows after each change. See [In-app purchases and subscriptions](https://hatang.dev/docs/guides/support/in-app-purchases.md#backend-only). ## Knowledge {#knowledge} ```python hatang.knowledge.sync("help", [ {"external_id": f"help:{a.slug}", "title": a.title, "content": a.body, "url": a.url, "locale": "vi"} for a in articles ]) # the collection then holds exactly these; unchanged documents are not re-indexed hatang.knowledge.search("đổi trả trong bao lâu", locale="vi") ``` See [Knowledge API](https://hatang.dev/docs/guides/support/knowledge-api.md). ## Conversations and customers {#chat} ```python convs = hatang.chat.conversations.list(status="open", unanswered=True) hatang.chat.conversations.send(conv_id, {"content": "Đơn của bạn đã được gửi."}, idempotency_key=f"shipped:{order.id}") hatang.chat.conversations.send(conv_id, {"content": "Khách VIP", "visibility": "private"}) # internal note hatang.chat.customers.update(customer_id, {"stage": "won", "fields": {"plan": "pro"}}) csv_text = hatang.chat.customers.export_csv({"stage": "won"}) ``` Products and orders: `hatang.chat.products` (`list`, `search`, `get`, `create`, `update`, `remove`) and `hatang.chat.orders` (`list`, `get`, `create`, `update`, `payment_link`). ## Email {#emails} `from` is a Python keyword, so write the body as a dict literal: ```python hatang.emails.send({ "from": "Shop ", "to": "an@example.com", "subject": "Đơn hàng DH12", "html": "

Cảm ơn bạn!

", "attachments": [{"filename": "invoice.pdf", "content": pdf_bytes}], # bytes are base64-encoded for you }, idempotency_key=f"order-mail:{order.id}") hatang.batch.send([...]) # up to 100 ``` ## Payments and IPN {#payments} ```python payment = hatang.payments.create({"amount": 199000, "description": "DH12", "ipn_url": "https://shop.vn/hatang/ipn"}) from hatang import construct_ipn_event def ipn(request): # Django; pass the raw body before parsing it event = construct_ipn_event(request.body, request.headers, settings.HATANG_SECRET_KEY) if event["data"]["status"] == "paid": mark_paid(event["data"]["metadata"]["order_id"]) ``` ## Webhooks {#webhooks} ```python from hatang import HatangSignatureError, construct_webhook_event @app.post("/hatang/webhook") # FastAPI async def webhook(request: Request): try: event = construct_webhook_event(await request.body(), request.headers, WEBHOOK_SECRET) # whsec_... except HatangSignatureError: raise HTTPException(400) if event["type"] == "chat.message.created": ... ``` Deliveries older than 5 minutes are refused (`tolerance_sec` changes it). `verify_webhook` and `verify_ipn` return a bool instead of raising. ## Identity hash and purchase token {#identity-hash} ```python from hatang import identity_hash, purchase_token identity_hash(str(user.id), settings.HATANG_IDENTITY_SECRET) # return {userId, hash} to the widget or the app purchase_token("site_...", str(user.id)) # StoreKit appAccountToken / Play obfuscatedAccountId ``` Both match Ha Tang's servers and the app SDKs value for value. See [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md). ## Types {#types} Request bodies are `TypedDict`s in `hatang.types` (`ChatSubscriptionOptions`, `ChatPurchaseOptions`, `KnowledgeDocumentInput`, `CreateEmailOptions`, `CreatePaymentOptions`…), so mypy and your editor check the field names. The package ships `py.typed`. ## Next steps - [In-app purchases and subscriptions](https://hatang.dev/docs/guides/support/in-app-purchases.md) - [Knowledge API](https://hatang.dev/docs/guides/support/knowledge-api.md) - [REST API reference](https://hatang.dev/docs/reference/api.md) for everything not wrapped yet Source: https://hatang.dev/docs/guides/sdks/python --- # React and Next.js (`@hatang/chat-react`) `@hatang/chat-react` loads the Ha Tang chat widget in a React or Next.js app. It gives you a component that renders nothing and injects the widget script once, and a hook that returns the typed [`$hatang` JavaScript API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md). The widget is still the web widget, with the same look and the same dashboard settings; this package only handles loading it and the TypeScript types. > **Info: Version** > > `@hatang/chat-react` **0.1.2** · React 18+ · Next.js 14+ (App Router and Pages Router) · [npm](https://www.npmjs.com/package/@hatang/chat-react) · changelog: `CHANGELOG.md` in the package ## Before you start - A website channel in Ha Tang. Copy its **Site ID** (`site_...`) from **Support → Channels** → your website. - The domain your app runs on must be the website's domain (or one of its subdomains), or be listed under **Extra allowed origins** in the website's settings. Add `http://localhost:3000` there for local development. ## Install ```bash npm install @hatang/chat-react ``` ## Load the widget {#load} Render `` once, in your root layout. The module starts with `"use client"`, so you can use it directly in the App Router. ```tsx title="app/layout.tsx" import { HatangChat } from "@hatang/chat-react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` On first render the component sets `window.HATANG_SITE_ID`, sets `window.HATANG_CONFIG` when you pass `config`, and appends `https://chat.hatang.dev/widget.js` with `async`. It never adds the script twice, even when it renders again or more than once. Once the first page loads, the website in the dashboard shows that the widget is installed. ### Props {#props} | Prop | Type | Default | What it does | |---|---|---|---| | `siteId` | `string` | required | The website's Site ID | | `host` | `string` | `https://chat.hatang.dev` | Where `widget.js` is served from. Change it only for a local gateway | | `config` | `HatangConfig` | — | `{ position?: "left" \| "right"; locale?: "vi" \| "en"; hideLauncher?: boolean }`, written to `window.HATANG_CONFIG` before the script loads | | `user` | `HatangIdentity` | — | `{ userId?, email?, name?, phone?, hash?, data? }`. Calls `identify` when the widget is ready, and again whenever `userId`, `email`, `name` or `hash` changes | `config` is read once, when the script is first added; later changes to it do not reach the widget. To switch language at runtime, call `setLocale`. The `HATANG_CONFIG` options `offset` and `mobileOffset` are not in the `HatangConfig` type yet; if you need them, set `window.HATANG_CONFIG` yourself before the component renders (see [Web widget and JS API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md)). ## Call the widget from your code {#api} `useHatang()` (a hook) and `hatang()` (a plain function, for code outside components) return the same object. Calls made before the widget has loaded are queued, so you can call them at any time in the browser. On the server they do nothing. ```tsx "use client"; import { useHatang } from "@hatang/chat-react"; export function SupportButton() { const chat = useHatang(); return ; } ``` | Method | What it does | |---|---| | `open()`, `close()`, `toggle()` | Open, close or toggle the chat window | | `hide()`, `show()` | Hide or show the launcher button (for example on a checkout page) | | `identify(identity)` | Attach the signed-in user; `hash` is computed on your server ([Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md)) | | `setData(data)` | Custom attributes shown to agents | | `setLocale("vi" \| "en")` | Change the widget's language | | `pushEvent(name, data?)` | Record an event on the visitor's timeline | | `on(event, cb)`, `off(event, cb)` | Listen to `ready`, `open`, `close`, `message:received`, `message:sent`, `conversation:status` | | `reset()` | Forget the current visitor (on sign-out). The widget reloads the page to start a fresh session | ```tsx "use client"; import { useEffect } from "react"; import { hatang } from "@hatang/chat-react"; export function ChatAnalytics() { useEffect(() => { const chat = hatang(); const onReply = (m?: unknown) => track("chat_reply", m); chat.on("message:received", onReply); return () => chat.off("message:received", onReply); }, []); return null; } ``` The widget also takes commands this typed API does not list, such as `lead`. Push them on the queue yourself: `window.$hatang.push(["lead", { email, name }])`. The full command and event list is in [Web widget and JS API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md). ## Identify signed-in users {#identify} Compute `hash`, the HMAC-SHA256 of the user id with the site's identity secret, on the server and pass it down with the user. Never put the secret in the client bundle. ```tsx title="app/layout.tsx (Server Component)" import { createHmac } from "node:crypto"; import { HatangChat } from "@hatang/chat-react"; import { currentUser } from "@/lib/auth"; export default async function RootLayout({ children }: { children: React.ReactNode }) { const user = await currentUser(); const identity = user ? { userId: user.id, email: user.email, name: user.name, hash: createHmac("sha256", process.env.HATANG_IDENTITY_SECRET!).update(user.id).digest("hex"), } : undefined; return ( {children} ); } ``` With a valid `hash`, the inbox marks the visitor as verified and merges the conversation with that user's earlier sessions, including those from your mobile app. Call `hatang().reset()` when the user signs out so the next person on the same browser does not inherit the identity. ## Without `useEffect` {#snippet} For the Pages Router, other frameworks or a plain HTML template, `hatangSnippet(siteId, host?, config?)` returns the same inline script the dashboard shows, as one string. Put it in a ` ``` ## Exports {#exports} `HatangChat`, `HatangChatProps`, `hatang`, `useHatang`, `HatangApi`, `HatangIdentity`, `HatangEvent`, `HatangConfig`, `hatangSnippet`, `inlineSnippet` and `widgetUrl`. ## Changelog {#changelog} **0.1.1**: the npm page describes the widget and Ha Tang and links this guide; the README ends with an About Ha Tang section. No code changes. **0.1.0**: first release. `` loads the widget once; `useHatang()` / `hatang()` with `open`, `close`, `toggle`, `hide`, `show`, `identify`, `setData`, `setLocale`, `pushEvent`, `on`, `off` and `reset`, queued until the widget loads. ## Next steps - [Web widget and JS API](https://hatang.dev/docs/guides/support/web-widget/javascript-api.md): every `HATANG_CONFIG` option, the `lead` command and conversion tracking. - [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md): signed identities and how sessions merge. - [Install the widget](https://hatang.dev/docs/guides/support/web-widget/install.md): Shopify, Google Tag Manager, Webflow and plain HTML. Source: https://hatang.dev/docs/guides/sdks/react --- # React Native SDK `@hatang/chat-react-native` > **Info: Version** > > `@hatang/chat-react-native` **0.4.2** · protocol v1 · React Native 0.76+ (New Architecture or not) · Expo SDK 52+ · React 18+ · [npm](https://www.npmjs.com/package/@hatang/chat-react-native) · [Compatibility](https://hatang.dev/docs/guides/sdks/compatibility.md) > **Note** > > Version 0.3 has the chat screen, the visitor's conversation inbox, hooks for your own UI, signed identity, photo and file attachments, product, order and payment cards with in-app purchase hooks, push notifications through your own Firebase project, conversion events, deep links that restore a session, and a chat screen that follows the site's widget settings. The API may still change before 1.0. Support chat for React Native and Expo apps. The SDK talks the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md) directly to the Ha Tang gateway (no web view), like the [Flutter](https://hatang.dev/docs/guides/sdks/flutter.md), [iOS](https://hatang.dev/docs/guides/sdks/ios.md) and [Android](https://hatang.dev/docs/guides/sdks/android.md) SDKs. The UI is built entirely from React Native core components and the package has no native code of its own; Expo modules (secure storage, device info, image and file pickers) are optional and loaded when installed. Customers who write from your app land in the same inbox as visitors from your website, Zalo, Messenger, Instagram and TikTok Shop. ## Installation {#installation} ### Requirements | | Version | |---|---| | React Native | 0.76+ (New Architecture or the old architecture) | | Expo | SDK 52+ (managed or bare) | | React | 18+ | ### Get an app key from the dashboard The SDK needs two values: the **Site ID** and an **app key** (`hak_…`). The key replaces the browser's Origin check: the app sends it on start, so the gateway knows which app the session comes from. 1. In the dashboard, open **Support → Channels → Add channel → Mobile app**. 2. Click **Create app key**: give it a name (for example "Acme RN"), pick the platform, and optionally add bundle ids. 3. The key is shown once. Pass it to `Hatang.configure({ appKey })`. The key is public like the site id (it ships inside your build) but it can be revoked. To rotate it safely, create a new key, ship an update that uses it, then revoke the old one. Sessions that use a revoked key are cut off within a minute. ### Add the package ```bash # Expo: the package and the optional modules, at the versions that match your Expo SDK npx expo install @hatang/chat-react-native expo-secure-store expo-application expo-device expo-image-picker expo-document-picker react-native-safe-area-context # Bare React Native npm install @hatang/chat-react-native # or pnpm add @hatang/chat-react-native ``` ### Optional Expo modules Every `expo-*` package is an **optional peer dependency**. The SDK loads them at runtime when they are installed; if one is missing, the matching feature is turned off and the app still runs: | Package | Used for | When it is missing | |---|---|---| | `expo-secure-store` | The visitor token in the Keychain / Keystore, and a few small session settings | Kept in memory: the visitor is forgotten every time the app restarts, and the SDK logs a warning. Pass `storage` instead (see [Storage](#storage)). | | `expo-application` | The app version (`1.4.0+12`) | No app version is sent | | `expo-device` | Device model and OS version | Uses `Platform.Version`, no model | | `expo-image-picker` | "Take photo" and "Photo library" in the composer | Those two entries are hidden | | `expo-document-picker` | "Choose file" in the composer | That entry is hidden | | `react-native-safe-area-context` | Keeps the chat screen clear of the notch, status bar and home indicator | iOS falls back to React Native's `SafeAreaView` (with a deprecation warning); Android leaves no margin. Or pass `insets`. | The attach button only shows when at least one picker is installed. Bare React Native apps can use these modules too once [`expo`](https://docs.expo.dev/bare/installing-expo-modules/) (Expo Modules) is installed. ### Platform permissions Photo attachments need a usage description on iOS. With Expo, set it through the `expo-image-picker` config plugin: ```json title="app.json" { "expo": { "plugins": [ ["expo-image-picker", { "photosPermission": "Send photos to our support team", "cameraPermission": "Take photos to send to our support team" }] ] } } ``` Bare React Native apps add `NSPhotoLibraryUsageDescription` and `NSCameraUsageDescription` to `ios//Info.plist`. Camera permission is requested on first use. Files can be up to 10 MB: images (the picker converts HEIC to JPEG), PDF, office documents, zip, mp4 and m4a. On Android, keep `softwareKeyboardLayoutMode: "resize"` (Expo's default, equivalent to `android:windowSoftInputMode="adjustResize"`): the chat screen relies on the window shrinking when the keyboard opens. On iOS, `KeyboardAvoidingView` handles it. Expo Go runs the SDK as long as you only use `expo-secure-store`, `expo-application`, `expo-device` and the pickers. Push notifications through Firebase need a [development build](https://docs.expo.dev/develop/development-builds/introduction/). ## Initialization {#initialization} Call `Hatang.configure` once at start-up, outside any component (for example at the top of `App.tsx` or `index.js`). The SDK bootstraps the visitor, stores the token and opens the socket right away (`autoConnect: true`). ```ts title="hatang.ts" import { Hatang } from "@hatang/chat-react-native"; Hatang.configure({ siteId: "site_…", appKey: "hak_…", // baseUrl: "https://chat.hatang.dev", // default // locale: "en", // autoConnect: false, // call Hatang.connect() yourself later }); ``` | Option | Meaning | |---|---| | `siteId`, `appKey` | Required. From the website's **Apps** tab | | `baseUrl` | The gateway. Default `https://chat.hatang.dev`; change it only for a local gateway (see [Troubleshooting](#troubleshooting)) | | `locale` | `"vi"` / `"en"`. When unset: the stored choice, then the site, then the device | | `storage`, `secureStorage` | Your own storage, see [Storage](#storage) | | `autoConnect` | `false` to call `Hatang.connect()` yourself later (for example after a consent screen) | | `openOnProactiveMessage` | Open the chat when the team starts a conversation (default `true`) | | `commerce` | How "Buy" and "Pay" on cards behave, see [Sales cards and in-app purchase](#commerce) | | `logger` | `{ debug?, info?, warn?, error? }` for logs while debugging | | `app` | Overrides for the app details sent to the gateway (`version`, `os`, `osVersion`, `deviceModel`…) | Calling `configure` again replaces the previous engine (listeners added with `Hatang.on` are kept). The facade also has `Hatang.connect()`, `Hatang.disconnect()`, `Hatang.dispose()`, `Hatang.isConfigured`, `Hatang.session` (`visitorId`, `conversationId`, `locale`, `isIdentified`, `email`, `name`), `Hatang.unreadCount`, `Hatang.availability` and `Hatang.connection`. `Hatang.engine` returns the full `ChatEngine` for advanced cases. The SDK follows `AppState`: when the app goes to the background the gateway is told at once (`app.state`) so new replies go out as push notifications, and the socket closes about 10 seconds later. Back in the foreground, the SDK reconnects and catches up on missed messages. ### Storage {#storage} By default the SDK uses `expo-secure-store` for both the visitor token and the small session settings. Keys are readable after the first device unlock, so a push that wakes the app in the background can still read the token. Without `expo-secure-store` and without `storage`, the SDK keeps everything in memory and the visitor is treated as new on every launch. `storage` takes an AsyncStorage-like object (`getItem`, `setItem`, `removeItem`) or the SDK's `{ get, set, remove }` shape (every function returns a `Promise`): ```ts import AsyncStorage from "@react-native-async-storage/async-storage"; import * as Keychain from "react-native-keychain"; import { Hatang, type HatangStorage } from "@hatang/chat-react-native"; // A token adapter built on react-native-keychain const keychainStore: HatangStorage = { get: async (key) => { const c = await Keychain.getGenericPassword({ service: key }); return c ? c.password : null; }, set: async (key, value) => { await Keychain.setGenericPassword("hatang", value, { service: key }); }, remove: async (key) => { await Keychain.resetGenericPassword({ service: key }); }, }; Hatang.configure({ siteId: "site_…", appKey: "hak_…", storage: AsyncStorage, secureStorage: keychainStore }); ``` Order of choice: the visitor token goes to `secureStorage`, else `expo-secure-store`, else `storage`. So when `expo-secure-store` is installed, the token always lives there even if you pass `storage`. Avoid putting the token in AsyncStorage if you have another option: AsyncStorage is not encrypted. ## Identify visitors {#identify-visitors} When a user signs in, call `identify` with their id in your system. `hash` is the HMAC-SHA256 (hex) of `userId` with the site's identity secret, **computed on your backend** and returned to the app. Never put the secret in the app. A signed identity is merged with earlier sessions of the same person (including on the web); an unsigned one is only stored as self-reported details. See [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md). ```ts // Your backend returns { userId, hash } after sign-in await Hatang.identify({ userId: me.id, email: me.email, name: me.name, phone: me.phone, hash: me.hatangHash, data: { plan: "pro", orders: 12 }, }); ``` One line on your backend computes `hash`: ```js title="Node" const hash = require("node:crypto").createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(userId).digest("hex"); ``` ```php title="PHP" $hash = hash_hmac('sha256', $userId, getenv('HATANG_IDENTITY_SECRET')); ``` When the identity already belongs to another visitor (the user chatted on another device, for example), the gateway merges the sessions: the SDK receives a new token, reloads the conversation and emits `sessionChanged` with `reason: "identity_merged"`. You do not need to do anything else. ### Sign-out ```ts await Hatang.reset(); ``` `reset()` revokes the old token, clears local state, starts a new visitor and registers the push token again for that visitor, so the next user on the same device does not see the previous user's conversations. To stop push for this device entirely, call `Hatang.clearPushToken()` before `reset()`. ## Open and embed the chat {#open-and-embed} ### HatangProvider and the default modal Wrap your app in `HatangProvider`. When you do not pass `onOpenRequest`, the provider opens `HatangChatScreen` in a full-screen modal when `Hatang.open()` is called, when the user taps a push notification, or when the team starts a conversation. ```tsx title="App.tsx" import { Button } from "react-native"; import { Hatang, HatangProvider, useHatangUnread } from "@hatang/chat-react-native"; export default function App() { return ( ); } function SupportButton() { const unread = useHatangUnread(); return
Questions? Ask us ``` To use only your own button, hide the launcher with `window.HATANG_CONFIG = { hideLauncher: true }` and show the unread count yourself with the `unread` event. ### identify {#identify} ```js $hatang.push(["identify", { user_id: "u_42", email: "ha@example.com", name: "Nguyen Thu Ha", phone: "0912345678", hash: "", data: { plan: "pro", orders: 12 } }]); ``` | Field | Type | Limit | |---|---|---| | `user_id` | string | Up to 200 characters. Your own user ID. | | `email` | string | A valid email address, up to 320 characters. | | `name` | string | Up to 200 characters. | | `phone` | string | Up to 40 characters. | | `hash` | string | `HMAC-SHA256(identity secret, user_id)` in hex, computed on your server. | | `data` | object | Custom attributes, up to 8 KB as JSON. | Without `hash`, the details are stored as the visitor's own claim and the inbox marks the visitor **Unverified**. With a valid `hash`, the inbox marks the visitor **Verified** and links all their sessions. Read [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md) before you use `user_id`. > **Caution** > > The field name is `user_id`, in snake case. Other keys are dropped. If `email` is not a valid address, or a value is over its limit, the whole `identify` is rejected, so validate on your side first. ### setData ```js $hatang.push(["setData", { plan: "pro", cart_value: 450000, last_order: "DH-1042" }]); ``` `setData` is `identify` with only `data`. Agents see the attributes on the visitor's profile in the inbox. New keys are added and existing keys are overwritten; keys you do not send are kept. Each call can carry up to 8 KB of JSON. ### pushEvent ```js $hatang.push(["pushEvent", "viewed_pricing", { plan: "pro" }]); ``` During a conversation, the event appears in it, so agents see what the visitor does while they chat. The name can be up to 100 characters. Before the visitor writes, events and the pages they visit are kept as their journey: the last 20 steps, for 24 hours. When the visitor starts a conversation (a first message, the offline form, a lead or a campaign), the journey is attached to it and shown to agents as **Before writing** in the thread and the visitor sidebar. Visitors never see it. Repeats of the same page in a row count once. Each event's data is cut to 1 KB of JSON, and values under keys that look secret (anything containing `password`, `token`, `secret`, `card`, `cvv` or `otp`, in any case) are stored as `[redacted]`, also in page URLs. Do not send personal data you would not show an agent. The widget also reports page changes on its own, including URL changes in single-page apps (`history.pushState`, `replaceState` and the back button), so agents see the current page in **Support → Live** and the inbox. ## Events {#events} Subscribe with `on`, unsubscribe with `off`: ```js function onReply(message) { console.log("New reply from", message.sender_type, message.content); } $hatang.push(["on", "message:received", onReply]); // later $hatang.push(["off", "message:received", onReply]); ``` | Event | Data | When | |---|---|---| | `ready` | `{ visitor_id, conversation_id }` | The widget has started. `conversation_id` is `null` if the visitor has no open conversation. | | `open` | none | The chat window opened. | | `close` | none | The chat window closed. | | `message:received` | `{ id, sender_type, content }` | An agent, the AI assistant or the system wrote in the visitor's conversation. `sender_type` is `agent`, `bot` or `system`. | | `message:sent` | `{ id, sender_type, content }` | A visitor message was stored, including one sent from another tab. `sender_type` is `visitor`. | | `conversation:status` | `{ status, conversation_id }` | The conversation changed status: `open`, `pending`, `resolved` or `closed`. | | `unread` | `{ count }` | The number of unread replies changed. It is reset to 0 when the visitor opens the chat. | | `conversion` | `{ name, ...data }` | A conversion was tracked: `name` is `chat_started`, `lead` or `purchase`. Fires only when conversion tracking is on. | > **Tip** > > `ready` fires once, right after the queued commands run. Push your `on("ready", ...)` before the widget script loads, as part of the first snippet tag, or you will miss it. ### Example: your own unread badge ```html ``` ## Leads from your own forms {#leads} Any form on your site can create a lead. The widget identifies the visitor with the details, opens a conversation (or reuses the visitor's active one) whose message summarizes the form, routes it by your distribution rules and counts a `lead` conversion. ```js document.querySelector("#quote-form").addEventListener("submit", function (e) { e.preventDefault(); const f = new FormData(e.target); $hatang.push(["lead", { name: f.get("name"), phone: f.get("phone"), email: f.get("email") || undefined, message: f.get("message"), source: "pricing-form", fields: { city: f.get("city"), budget: f.get("budget") } }]); }); ``` | Field | Type | Limit | |---|---|---| | `name` | string | Up to 200 characters. | | `phone` | string | Up to 40 characters. A phone or an email is required. | | `email` | string | A valid email, up to 320 characters. | | `message` | string | Up to 2,000 characters. | | `source` | string | Up to 100 characters. Shown in the conversation and used as the campaign name in analytics. Default `form`. | | `fields` | object | String values only: keys up to 60 characters, values up to 500. Stored on the visitor and on the customer profile. | Each lead also sends the `chat.campaign.lead` [webhook](https://hatang.dev/docs/guides/getting-started/webhooks.md) event. ### Without the widget A static landing page can post the same JSON straight to the chat gateway. The request must come from a browser page on an allowed origin of the site; calls without an `Origin` header are rejected. ```js await fetch("https://chat.hatang.dev/widget/v1/sites/site_123.../leads", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Ha", phone: "0912345678", message: "Pro plan quote", source: "landing-tet" }) }); ``` A successful call returns `201`: ```json { "ok": true, "conversation_id": "cnv_123...", "visitor_id": "vis_123...", "customer_id": "cus_123..." } ``` | Status | Error | Cause | |---|---|---| | 403 | `origin_not_allowed` | The page's origin is not allowed for the site. | | 403 | `site_disabled` | The site is disabled. | | 422 | `validation_error` | No phone and no email, an invalid email, or a field over its limit. | | 429 | `rate_limit_exceeded` | More than 10 leads per minute from one IP address. | Add a hidden `website` input to your form and send its value: it is a honeypot. Real people leave it empty; if a bot fills it, the gateway answers `200 { "ok": true }` and stores nothing. ## Conversion tracking {#conversion-tracking} Turn it on under **Support → Channels → your website → Automation → Conversion tracking**. The widget then reports three conversions: | Conversion | When | gtag event | dataLayer event | Meta Pixel | |---|---|---|---|---| | `chat_started` | The visitor sends the first message of a conversation. | `chat_started` | `hatang_chat_started` | `Contact` | | `lead` | The visitor submits a campaign form, or the page calls `lead`. | `generate_lead` | `hatang_generate_lead` | `Lead` | | `purchase` | An order in the chat is paid. | `purchase` | `hatang_purchase` | `Purchase` | - If `gtag` is on the page, the widget calls `gtag("event", ...)` with `source: "hatang_chat"`. If you set **Google Ads conversion (send_to)**, it also sends a `conversion` event to that target. - If there is no `gtag` but `window.dataLayer` exists, it pushes the dataLayer event for Google Tag Manager. - If `fbq` is on the page, it tracks the Meta Pixel event. - In every case, it emits the `conversion` widget event. Fill **GA4 measurement id**, **GA4 API secret**, **Meta pixel id** and **Meta Conversions API token** to also send server-side events (GA4 Measurement Protocol, Meta Conversions API). These are not blocked by ad blockers and also count orders paid from Zalo, Messenger and Instagram. When it starts, the widget sends the `_ga`, `_fbp` and `_fbc` cookies so both sides join the same analytics session. ## Next steps - [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md): sign `identify` so the inbox can trust `user_id`. - [React SDK](https://hatang.dev/docs/guides/sdks/react.md): `` and `useHatang()` wrap this API. - [Realtime](https://hatang.dev/docs/guides/support/realtime.md) and the [realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md): build your own client. Source: https://hatang.dev/docs/guides/support/web-widget/javascript-api --- # Zalo ZNS ZNS (Zalo Notification Service) sends a template message to a phone number through your Zalo Official Account (OA). The customer does not need to have messaged you first, which makes ZNS a good fit for order confirmations, payment reminders and delivery updates. You can send ZNS from an order page, to a filtered list of customers, from a workflow, or through the API. Zalo charges for each message. ## Before you start 1. **Connect a Zalo Official Account** under **Support → Channels**. See [Zalo OA](https://hatang.dev/help/channels/zalo-oa). The dashboard offers only accounts whose status is **Active**. 2. **Put the OA on a paid Zalo tier** that includes ZNS. Without it, Ha Tang cannot load your templates. 3. **Create templates and get them approved** in the Zalo OA admin: oa.zalo.me → ZNS → Templates. Ha Tang lists only approved templates. 4. **Keep the CRM (Customers) module on.** ZNS belongs to the Customers module: the API endpoints sit in that module, and queued sends are skipped while it is off. See [Businesses and modules](https://hatang.dev/help/getting-started/modules). ## The Zalo ZNS page Open **Support → Channels**. The **Zalo ZNS** card has a **ZNS settings** button that opens the Zalo ZNS page. It shows, for the selected OA: - **Quota today**: messages left today out of the OA's daily quota. - Totals: sent, failed and queued messages. - Approved templates with their name, id, status, quality and price per message. - **Recent sends** with the time, phone number, template, status (and Zalo's error when it failed), and links to the order and customer. If the business has several OAs, pick one at the top of the page. ## Send a notification for an order 1. Open the order (**Orders**, then the order). 2. On the **Zalo ZNS** card, click **Send notification**. 3. Pick the OA and the template. The phone number comes from the order's shipping address; it must be a Vietnamese mobile number. 4. Fill in the template parameters. Ha Tang pre-fills parameters whose names suggest the customer's name, the order code, the total or the phone number. 5. Click **Send ZNS**. The message is sent right away. The card shows **Sent (msg …)** with Zalo's message id, or Zalo's error. ## Send to a list of customers 1. Open **Customers** and filter the list (search, stage, owner, label, channel). 2. Click **Send ZNS** (the same page is in the sidebar as **ZNS sending**). 3. Choose the OA and the template, and set **Max messages** (up to 5,000). 4. Map each **Template parameter** to a value: | Value | Meaning | |---|---| | `customer.name`, `customer.phone`, `customer.email` | The customer's details | | `field:` | A custom field, for example `field:member_tier` | | `text:` followed by fixed text | The same text for everyone, for example `text:20/11` | 5. Check the count: the page shows how many customers match and how many have a phone number. Then click **Queue N ZNS messages**. Customers without a phone number are skipped. Queued messages go out at the OA's pace, and the results appear under **Recent sends** on the Zalo ZNS page. Messages that hit Zalo's rate limit are retried, up to 3 attempts. > **Caution** > > Zalo charges for every message. Check the number of recipients and the template price before you queue a large send. ## Send from a workflow Add a **Send ZNS** step to a [workflow](https://hatang.dev/docs/guides/support/automation.md#workflows), for example on **An order is paid**. Pick the OA, enter the **ZNS template id** and the parameters, one `name=value` per line. Values can use `{{name}}`, `{{phone}}`, `{{email}}`, `{{order_code}}`, `{{order_total}}`, `{{rating}}` and `{{site}}`. The step uses the customer's phone number and is skipped when there is none. ## Send with the API | Endpoint | Purpose | |---|---| | `GET /chat/zns/templates?account_id=cha_…` | Approved templates of an OA, with today's quota | | `GET /chat/zns/templates/{id}?account_id=cha_…` | One template with its parameters | | `POST /chat/zns` | Send one template message now | | `POST /chat/zns/bulk` | Queue a template for every customer matching filters | | `GET /chat/zns` | Messages sent by the business (`customer_id`, `order_id`, `limit` filters) | Reads need the `crm:read` scope and sends need `crm:write`. Keys with `chat:read` / `chat:write` are accepted too. `account_id` is the id of the connected OA: the `cha_…` at the end of the OA's page address in the dashboard (`…/chat/channels/zalo/cha_…`). ### Send one message Body fields: | Field | Required | Notes | |---|---|---| | `account_id` | Yes | The Zalo OA account (`cha_…`) | | `template_id` | Yes | An approved template id | | `phone` | Yes | A Vietnamese mobile number; `0912 345 678`, `84912345678` and `+84 912 345 678` all work | | `template_data` | Yes | Template parameters, as strings (keys up to 60 characters, values up to 500) | | `customer_id`, `order_id` | No | Link the message to a customer and an order | | `tracking_id` | No | Your own reference, up to 60 characters; defaults to the Ha Tang message id | **cURL** ```bash curl -X POST https://api.hatang.dev/chat/zns \ -H "Authorization: Bearer ht_123..." \ -H "Content-Type: application/json" \ -d '{ "account_id": "cha_123", "template_id": "231234", "phone": "0912345678", "template_data": { "customer_name": "Lan", "order_code": "DH1024", "amount": "350.000đ" }, "order_id": "ord_123" }' ``` **Node.js** ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY); const { data, error } = await hatang.chat.zns.send({ account_id: "cha_123", template_id: "231234", phone: "0912345678", template_data: { customer_name: "Lan", order_code: "DH1024", amount: "350.000đ" }, order_id: "ord_123", }); if (error) throw new Error(error.message); if (data.status === "failed") console.error("Zalo refused:", data.error); ``` **Python** ```python import os, requests res = requests.post( "https://api.hatang.dev/chat/zns", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={ "account_id": "cha_123", "template_id": "231234", "phone": "0912345678", "template_data": {"customer_name": "Lan", "order_code": "DH1024", "amount": "350.000đ"}, "order_id": "ord_123", }, ) message = res.json() if message.get("status") == "failed": print("Zalo refused:", message["error"]) ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->post('/chat/zns', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'account_id' => 'cha_123', 'template_id' => '231234', 'phone' => '0912345678', 'template_data' => ['customer_name' => 'Lan', 'order_code' => 'DH1024', 'amount' => '350.000đ'], 'order_id' => 'ord_123', ], ]); $message = json_decode($res->getBody(), true); ``` Response (`201 Created`): ```json { "object": "chat_zns_message", "id": "zns_...", "account_id": "cha_123", "customer_id": null, "order_id": "ord_123", "phone": "84912345678", "template_id": "231234", "template_name": null, "template_data": { "customer_name": "Lan", "order_code": "DH1024", "amount": "350.000đ" }, "tracking_id": null, "msg_id": "f1d2...", "status": "sent", "error": null, "created_at": "2026-10-23T03:00:00.000Z" } ``` > **Note** > > The call returns `201` even when Zalo refuses the message. Check `status`: `sent` means Zalo accepted it (`msg_id` is Zalo's id); `failed` means Zalo refused it, and `error` holds Zalo's error code and message. ### Send to many customers `POST /chat/zns/bulk` takes `account_id`, `template_id`, `mapping` (each template parameter mapped to `customer.name`, `customer.phone`, `customer.email`, `field:` or `text:`), optional `filters` (`q`, `stage`, `owner_id`, `label`, `channel`) and `limit` (1 to 5,000, default 1,000). It responds `202 Accepted`: ```json { "object": "chat_zns_bulk", "queued": 418, "skipped": 12 } ``` `skipped` counts customers without a phone number or with a number that is not a valid Vietnamese mobile number. ### Webhook events Each delivery attempt sends `chat.zns.sent` or `chat.zns.failed` with the message object above. See [Webhooks](https://hatang.dev/docs/reference/webhooks.md). ## Limits and quirks - Phone numbers are normalized to the `84…` form. A number that does not end up with 11 or 12 digits is rejected with `422 validation_error`. - Only approved templates can be sent. Templates with a status other than enabled do not appear in the list. - The daily quota belongs to the OA and is set by Zalo. The Zalo ZNS page and `GET /chat/zns/templates` show the remaining quota. - `account_id` must be a Zalo OA. A Zalo personal account cannot send ZNS. ## Troubleshooting | What you see | Cause | Fix | |---|---|---| | "Could not load templates: … ZNS needs the OA on a paid tier with approved templates." | The OA is on a free tier, or its connection expired | Upgrade the OA, or reconnect it under **Support → Channels** | | "No approved templates" | No template has been approved yet | Create templates at oa.zalo.me → ZNS → Templates and wait for approval | | "Connect a Zalo Official Account first" | No active Zalo OA | Connect the OA, or reconnect it if it needs re-authorization | | A send has status `failed` | Zalo refused it: wrong parameters, quota exhausted, or the phone number has no Zalo account | Read the `error`, fix the template data or wait for the quota to reset | | Bulk messages stay queued | The Customers module is off | Turn the module back on | ## Next steps - [Zalo OA](https://hatang.dev/help/channels/zalo-oa): connect the Official Account that sends ZNS. - [Automation](https://hatang.dev/docs/guides/support/automation.md): send ZNS automatically when an order is paid. - [Webhooks reference](https://hatang.dev/docs/reference/webhooks.md): track `chat.zns.sent` and `chat.zns.failed`. Source: https://hatang.dev/docs/guides/support/zns --- # Errors Every REST API error uses the same envelope. `name` is stable and meant for code; `message` is an English sentence for humans and may change. ```json { "statusCode": 422, "name": "validation_error", "message": "to: Invalid email address" } ``` As a rule, `4xx` means fix the request before sending it again; `429` and `5xx` are temporary, so retry with backoff (see [Limits](https://hatang.dev/docs/reference/limits.md)). See also the [error handling guide](https://hatang.dev/docs/guides/getting-started/errors.md). ## REST API | Code (`name`) | HTTP | Meaning and what to do | |---|---|---| | `validation_error` | 422 | The request body or a parameter is invalid; `message` names the field. | | `missing_required_field` | 422 | A required field is missing. | | `missing_api_key` | 401 | No `Authorization: Bearer` header. | | `invalid_api_key` | 403 | The API key is unknown or revoked. | | `restricted_api_key` | 401 | The key lacks the scope this endpoint needs, or is locked to other origins. | | `invalid_from_address` | 403 | The `from` domain is not verified for this business (or not allowed for this key). | | `not_found` | 404 | The resource does not exist or belongs to another business. | | `method_not_allowed` | 405 | The HTTP method is not supported on this path. | | `invalid_idempotent_request` | 400 | The `Idempotency-Key` was used before with a different body. | | `concurrent_idempotent_requests` | 409 | Another request with the same `Idempotency-Key` is still running; retry shortly. | | `rate_limit_exceeded` | 429 | Too many requests; wait for `retry-after` seconds. | | `daily_quota_exceeded` | 429 | The plan's daily sending quota is used up. | | `invalid_attachment` | 422 | An attachment is malformed, unreachable (`path`) or too large. | | `application_error` | 500 | Something failed on our side; retry with backoff. | | `internal_server_error` | 500 | Unexpected error; retry with backoff and contact support if it persists. | | `origin_not_allowed` | 403 | The browser origin is not one of the site's domain or allowed origins. | | `invalid_visitor_token` | 401 | The visitor token is missing, expired or for another site; bootstrap again. | | `invalid_agent_token` | 401 | The agent token is missing or expired; sign in again. | | `site_disabled` | 403 | The chat site is turned off. | | `invalid_app_key` | 401 | The mobile app key (`hak_…`) is unknown or for another site. | | `app_key_revoked` | 403 | The mobile app key was revoked; ship a build with a new key. | | `message_too_long` | 422 | The message is longer than allowed (see Limits). | | `unsupported_file_type` | 422 | This file type cannot be uploaded. | | `file_too_large` | 413 | The file is larger than allowed (see Limits). | | `conversation_closed` | 409 | The conversation is closed and takes no new messages. | | `product_not_enabled` | 403 | The product is not available to this business. | | `product_disabled` | 403 | The business switched this product off (Settings → Modules). | | `ai_budget_exhausted` | 429 | | | `ai_not_configured` | 503 | | | `ai_unavailable` | 503 | | | `instructions_full` | 409 | | | `forbidden` | 403 | You are not allowed to do this. | | `last_owner` | 409 | A business must keep at least one owner. | | `domain_taken` | 409 | The domain is already used by another business. | | `host_missing` | 422 | A host name is required. | | `host_mismatch` | 422 | The host does not match the domain. | | `slug_taken` | 409 | The slug is already in use. | | `slug_reserved` | 422 | The slug is reserved. | | `environment_mismatch` | 403 | The resource belongs to the other environment (development vs production). | | `no_bank_connection` | 422 | Link a bank account before creating payments. | | `code_taken` | 409 | The order or payment code is already used. | | `bank_link_failed` | 422 | The bank refused the link; check the account details. | | `invalid_otp` | 422 | The confirmation code is wrong. | | `otp_expired` | 410 | The confirmation code expired; request a new one. | | `invalid_state` | 409 | The resource is not in a state that allows this action. | | `provider_unavailable` | 503 | The bank or invoice provider is not answering; retry later. | | `invoice_provider_error` | 422 | The e-invoice provider rejected the request; `message` has its reason. | ## Realtime protocol `error` frames on the websocket carry one of these in `d.code`; codes shared with the REST API mean the same. | Code | Meaning | |---|---| | `validation_error` | The request body or a parameter is invalid; `message` names the field. | | `rate_limit_exceeded` | Too many requests; wait for `retry-after` seconds. | | `not_found` | The resource does not exist or belongs to another business. | | `unauthenticated` | A frame arrived before `auth` succeeded. | | `invalid_visitor_token` | The visitor token is missing, expired or for another site; bootstrap again. | | `origin_not_allowed` | The browser origin is not one of the site's domain or allowed origins. | | `site_disabled` | The chat site is turned off. | | `invalid_app_key` | The mobile app key (`hak_…`) is unknown or for another site. | | `app_key_revoked` | The mobile app key was revoked; ship a build with a new key. | | `conversation_closed` | The conversation is closed and takes no new messages. | | `file_too_large` | The file is larger than allowed (see Limits). | | `unsupported_file_type` | This file type cannot be uploaded. | | `bad_frame` | The frame is not valid JSON or does not match the protocol schema. | | `error` | Any other failure for one request. | ## SMTP relay | Reply | Meaning | |---|---| | `535 5.7.8` | invalid credentials (wrong API key) | | `530 5.7.0` | authentication required | | `550 5.7.1` | unauthorized or forbidden (for example an unverified `From` domain) | | `553 5.6.0` | invalid message (validation error) | | `452 5.5.3` | more than 50 recipients | | `421 4.7.0` | too many failed logins from this IP; try again later | | `451 4.3.0` | temporary failure; retry | Source: https://hatang.dev/docs/reference/errors --- # Limits ## Rate limits Limits apply per business and per API. Every response carries `ratelimit-limit`, `ratelimit-remaining` and `ratelimit-reset`; a `429` adds `retry-after` (seconds). Retry after that delay, with exponential backoff if it happens again. | API | Paths | Limit | |---|---|---| | Email and platform | everything else (`/emails`, `/domains`, `/webhooks`…) | 10 requests/second | | Support (chat) | `/chat/*` | 50 requests/second | | Payments | `/payments*`, `/einvoices*` | 20 requests/second | | Agent API (mobile and Mac apps) | `chat.hatang.dev/agent/*` | 300 reads and 60 writes per minute per user | Sending also counts against the plan's daily email quota (`daily_quota_exceeded`). ## Email | What | Limit | |---|---| | Recipients per email (`to` + `cc` + `bcc`) | 50 | | Emails per batch request | 1–100 | | Attachments per email | 50 | | Whole message (body + attachments) | 40 MB | | Subject | 998 characters | | Tags | 50 per email; names and values up to 256 characters of `A–Z a–z 0–9 _ -` | | `Idempotency-Key` | 256 characters | | SMTP relay message | 40 MB, 50 recipients | | Received email (inbound) | 25 MB, 50 attachments kept | ## Support (chat) | What | Limit | |---|---| | Websocket frame | 16 KB | | Customer message | 4000 characters | | Agent message | 10000 characters | | Attachments per message | 5 | | One attachment | 10 MB | | Custom visitor data (`identify.data`, `setData`) | 8 KB | | Messages a customer can send | 5 per 10 seconds, 60 per minute | More protocol timings (auth timeout, ping, reconnect backoff) are in the [realtime protocol reference](https://hatang.dev/docs/reference/realtime-protocol.md). Source: https://hatang.dev/docs/reference/limits --- # References The references describe every endpoint, event and frame exactly as the servers implement them. They are generated from the code and the published OpenAPI specs, so they change when the API changes. For step-by-step explanations, start with the [Guides](https://hatang.dev/docs/guides/getting-started/introduction.md). | Reference | What it covers | Base URL | |---|---|---| | [REST API](https://hatang.dev/docs/reference/api.md) | Email, Support (chat), Payments, API keys and webhooks: every endpoint with parameters, request and response schemas, code samples and a "Try it" console | `https://api.hatang.dev` | | [Chat gateway API](https://hatang.dev/docs/reference/chat-gateway.md) | What the website widget, the mobile SDKs and the agent apps call: bootstrap, conversations, uploads, devices, the agent API | `https://chat.hatang.dev` | | [Webhook events](https://hatang.dev/docs/reference/webhooks.md) | Every event with its payload | your endpoint | | [Realtime protocol](https://hatang.dev/docs/reference/realtime-protocol.md) | The visitor websocket: every frame, field and limit | `wss://chat.hatang.dev/ws` | | [Errors](https://hatang.dev/docs/reference/errors.md) | Every error code of the API, the realtime protocol and the SMTP relay | | | [Limits](https://hatang.dev/docs/reference/limits.md) | Rate limits and size limits | | ## SDK references Generated from the doc comments in each SDK's source, one site per SDK (English only): | SDK | Reference | |---|---| | Node (`@hatang/sdk`) | [TypeDoc](https://hatang.dev/docs/reference/js/index.html) | | Flutter (`hatang_chat`) | [dartdoc](https://hatang.dev/docs/reference/flutter/index.html) | | iOS (`HatangChat`, `HatangChatCore`) | [DocC](https://hatang.dev/docs/reference/ios/documentation) | | Android (`dev.hatang:chat`, `dev.hatang:chat-core`) | [Dokka](https://hatang.dev/docs/reference/android/index.html) | ## Conventions - **Authentication.** REST calls send `Authorization: Bearer ht_...`. See [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md) for scopes and test keys. - **Format.** JSON in and out, UTF-8. Timestamps are ISO 8601 in UTC; amounts in Vietnamese đồng are integers. - **Errors.** Every error is `{ "statusCode", "name", "message" }`; branch on `name`. - **Pagination.** List endpoints take `limit` and `after`, and answer with `has_more` and `next_cursor`. See [Pagination](https://hatang.dev/docs/guides/getting-started/pagination.md). - **Idempotency.** `POST /emails`, `POST /payments` and posting chat messages accept an `Idempotency-Key` header. See [Idempotency](https://hatang.dev/docs/guides/getting-started/idempotency.md). ## Machine-readable specs - REST API: [`openapi.json`](https://api.hatang.dev/openapi.json) (OpenAPI 3.1, including the `webhooks` section) - Chat gateway: [`openapi.json`](https://chat.hatang.dev/openapi.json) - Realtime protocol: JSON Schemas for [visitor frames](https://hatang.dev/docs/schema/chat-protocol-v1/visitor-frames.json), [server frames](https://hatang.dev/docs/schema/chat-protocol-v1/server-frames.json), [HTTP](https://hatang.dev/docs/schema/chat-protocol-v1/http.json) and [protocol constants](https://hatang.dev/docs/schema/chat-protocol-v1/protocol.json) - The whole hub for AI agents and LLM tools: [`llms.txt`](https://hatang.dev/docs/llms.txt) (an index), [`llms-full.txt`](https://hatang.dev/docs/llms-full.txt) (every page in one file), and a Markdown copy of each page at its URL plus `.md`, such as [`/docs/reference/overview.md`](https://hatang.dev/docs/reference/overview.md) Use them with any OpenAPI tool (Postman, Insomnia, code generators) or import them into your own tests. Source: https://hatang.dev/docs/reference/overview --- # Chat realtime protocol (v1) Every chat client (the web widget and the Flutter, React Native, iOS and Android SDKs) speaks the same WebSocket protocol with the gateway at `chat.hatang.dev`. Read this page to write your own client or to understand what the SDKs do under the hood. The frame tables further down are generated from the JSON Schema shipped with the `@hatang/chat-protocol` package, so they always match the gateway. Machine-readable versions: [`chat.hatang.dev/protocol.json`](https://chat.hatang.dev/protocol.json) (version, limits, close codes, error codes) and the JSON Schema files [`visitor-frames.json`](https://hatang.dev/docs/schema/chat-protocol-v1/visitor-frames.json), [`server-frames.json`](https://hatang.dev/docs/schema/chat-protocol-v1/server-frames.json), [`http.json`](https://hatang.dev/docs/schema/chat-protocol-v1/http.json) and [`protocol.json`](https://hatang.dev/docs/schema/chat-protocol-v1/protocol.json). What a native SDK must do on top of the protocol (session lifecycle, history, attachments, push) is checked by the conformance scenarios C01–C12 that every SDK passes before a release. ## Envelope {#envelope} 1. Call `POST /widget/v1/sites/{siteId}/bootstrap`. It creates or refreshes the visitor token and returns `ws_url`. A browser needs an Origin allowed for the site; a native app sends its app key in the `x-hatang-app-key` header instead, and the token it gets back carries the app's id. 2. Open `ws_url`: `wss://chat.hatang.dev/ws?site=`, plus `&app=&install=` for an app. Use it exactly as returned; an app socket needs no Origin. 3. Send `auth` as the first frame, within 5 seconds. Every frame is a JSON object: ```json { "t": "message.send", "id": "req_1", "v": 1, "d": { "client_id": "c_9f2", "content": "Is this still in stock?" } } ``` | Key | Meaning | |---|---| | `t` | Frame type | | `d` | Data | | `v` | Protocol version (`1`) | | `id` | Optional request id, echoed in `message.ack` or in `error.for` so you can match a reply to its request | Both sides send `ping` every 25 seconds and answer with `pong`. For the HTTP endpoints, send the visitor token in the `x-visitor-token` header. In the `identify` frame the user id field is `user_id`; every native SDK sends that name, and the web widget's `$hatang` API also accepts `userId` and maps it to `user_id`. ## Ordering and resume {#ordering-and-resume} Every message and event in a conversation carries a `seq` that grows by one. Store the highest `seq` you have seen. After a reconnect (or right after `ready` when you already have one), send `resume { conversation_id, after_seq }`; the server replays everything after it from the database, so a frame lost while the socket was down is never lost for good. Load older history over HTTP with `before_seq`, and catch up with `after_seq`. To send a message: 1. Generate a `client_id` for it and show the bubble as sending. 2. Send `message.send` and wait up to 10 seconds for `message.ack`. 3. On a timeout or an error, keep the bubble in a failed state and retry with the **same** `client_id`. The server answers a repeated `client_id` with the original message instead of creating a second one. 4. The `message` frame for your own message arrives too; dedupe it by `id`. ## Close codes {#close-codes} | Code | Meaning | What the client does | |---|---|---| | 4001 | No `auth` within 5 seconds | Reconnect and send `auth` first | | 4003 | Authentication refused or session revoked (app key revoked, for example) | Stop; drop the token and bootstrap a new visitor | | 4004 | The client's protocol is older than the gateway supports; a `reload` frame comes first | Browsers reload the page; native clients stop reconnecting and ask the user to update the app | | 4005 | A `session` frame just carried a new visitor token (identity merge) | Reconnect at once with the new token | | other | Network trouble, gateway restart | Reconnect with backoff `min(30 s, 500 ms · 2^n)` plus up to 500 ms of jitter | ## Error codes {#error-codes} HTTP errors are `{ statusCode, name, message }`; socket errors are an `error { code, message, for? }` frame. | Code | HTTP | Meaning | |---|---|---| | `invalid_app_key` | 401 | The `x-hatang-app-key` header is missing, wrong or belongs to another site | | `app_key_revoked` | 403 | The key was revoked in the dashboard; the app needs an update with a new key | | `invalid_visitor_token` | 401 | The visitor token expired, was reset or was revoked; bootstrap again | | `unauthenticated` | 401 | No token | | `origin_not_allowed` | 403 | The browser's Origin is not allowed for this site | | `site_disabled` | 403 | The site is turned off | | `product_disabled` | 403 | The business has turned off the Customer Support module ([Businesses and modules](https://hatang.dev/help/getting-started/modules)). The widget hides its button; the SDKs report the error when the chat starts and the rest of the app keeps working. Turning the module back on is enough to reconnect | | `validation_error` | 422 | The data has the wrong shape or exceeds a limit | | `rate_limit_exceeded` | 429 | See the rate limits below; try again later | | `conversation_closed` | 409 | Sending into a closed conversation | | `file_too_large`, `unsupported_file_type` | 413, 422 | Uploads: 10 MB at most; images (HEIC included), PDF, office files, zip, mp4 and m4a | | `not_found` | 404 | The conversation, message or file does not belong to this visitor | | `bad_frame` | — | The frame is not valid JSON or has no `t` | Rate limits: | What | Limit | |---|---| | Bootstrap | 30 per minute per app install, or per IP address for browsers | | Open sockets | 3 at a time per app install | | Visitor messages | 5 per 10 seconds and 60 per minute per visitor | | Uploads | 10 per minute per visitor | | Device registration | 10 per minute per visitor | ## Rendering cards {#card-rendering} A message whose `metadata.kind` is `product`, `order`, `payment`, `quick_replies` or `csat` carries structured data the client draws as a card: products, an order, a payment button, quick replies or a rating prompt. Its `content` is always plain text, so channels without cards can still read it. Tapping a quick reply sends its label as the visitor's message. `csat` invites a rating (stars in the widget, quick replies elsewhere), and the client answers with a `rate` frame. Unknown kinds are shown as plain messages. The shape of each card is in [Cards in a message's `metadata`](#cards). If you write your own client: - **Text above a card.** For `product`, `order` and `payment` cards, `content` is the full summary meant for email, push and other channels; do not show it in a bubble next to the card. Show only the intro line, `metadata.intro`, when there is one, and no bubble otherwise. `quick_replies`, `csat` and unknown kinds still show `content`. - **VietQR code.** `qr_image_url` is an SVG (`/pay/{id}/qr.svg`). Browsers draw it, but many mobile image loaders cannot. Draw the code yourself from `qr_payload` (the EMVCo string); for older cards without it, read `qr.payload` from the public `GET /pay/{id}`. - **Paid state.** The message holding a payment card is not updated when the customer pays. Treat the card as paid when its `status` is `paid` or `succeeded`, when the timeline has a message with `metadata.kind = "order_paid"` for the same `order_id`, or when an `order_paid` event has a matching `data.payment_id` or `data.order_id`. Then hide the QR code, the transfer note and the pay button. - **Apps that sell digital goods.** On iOS and Android, digital goods (courses, memberships and so on) must be sold through in-app purchase. The mobile SDKs have a `HatangCommerce` hook that hands the buy button to your in-app purchase code; physical goods keep VietQR. ## HTTP endpoints {#http-endpoints} Every path below is under `/widget/v1/sites/{siteId}/`. The full OpenAPI is in the [Chat gateway reference](https://hatang.dev/docs/reference/chat-gateway.md). | Endpoint | What it does | |---|---| | `POST bootstrap` | Creates or refreshes the visitor token. Returns `visitor`, `conversation`, `timeline`, `availability`, `ws_url` and the site config. Native SDKs send `app { install_id, version, sdk, os, os_version, device_model, locale }` and `screen` | | `GET conversations?status=all\|open\|resolved&limit&after` | The visitor's conversations, resolved ones included, for an in-app inbox | | `POST conversations` | Returns the visitor's active conversation, or opens a new one | | `GET conversations/{id}/messages?before_seq&limit` or `?after_seq` | A page of older history, or a catch-up | | `POST conversations/{id}/messages` | Send a message over HTTP, when no socket is available | | `POST uploads` | Multipart `file`; returns `{ id, name, size, type }`. Then send `message.send { content: "", attachments: [id] }` | | `PUT devices`, `DELETE devices` | Register or remove a push token ([Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md)) | | `POST leads`, `POST offline-message` | A lead from a form; a message left outside office hours | | `POST reset` | Forget the visitor; the old token stops working and a new one is returned | | `POST sessions/restore` | Swap the token from a "we replied" email link (`/c/`) for that visitor's session | Attachment URLs in `attachments[].url` are signed links to `GET /files/{id}?exp&sig` and stay valid for 24 hours. When one expires, load the message again to get a fresh link. ## Limits and timing Protocol version: **1**. | Limit | Value | |---|---| | One frame | 16384 B | | Visitor message | 4000 chars | | Agent message | 10000 chars | | Attachments per message | 5 | | One attachment | 10 MB | | Custom data (`identify.data`, `setData`) | 8 KB | | Send `auth` within | 5 s | | `ping` interval (both sides) | 25 s | | Wait for `message.ack` / a reply | 10 s | | Reconnect | `min(30 s, 500 ms · 2^n)` + jitter ≤ 500 ms; immediately after close codes 4005 | Close codes: `4001` auth_timeout · `4003` auth_failed · `4004` protocol_outdated · `4005` session_replaced. Error codes: `validation_error`, `rate_limit_exceeded`, `not_found`, `unauthenticated`, `invalid_visitor_token`, `origin_not_allowed`, `site_disabled`, `invalid_app_key`, `app_key_revoked`, `conversation_closed`, `file_too_large`, `unsupported_file_type`, `bad_frame`, `error`. ## Visitor → server frames ### `auth` First frame, within 5 seconds of opening the socket. Browsers send `page`; native SDKs send `screen`, `app` and `client`. `app_state` lets a socket opened in the background avoid counting as online. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `token` | `string` | yes | ≤ 2048 chars | | `site_id` | `string` | yes | 1–64 chars | | `page` | `object {url, title?, referrer?}` | no | | | `screen` | `object {name, params?}` | no | | | `app` | `object {install_id, version?, sdk?, os?, os_version?, device_model?, locale?}` | no | | | `client` | `object {sdk, version}` | no | | | `locale` | `string` | no | ≤ 10 chars | | `app_state` | `object {foreground}` | no | | ### `page` The visitor moved to another page (browser); recorded on the active conversation, or in the journey before writing. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `url` | `string` | yes | ≤ 2048 chars | | `title` | `string` | no | ≤ 300 chars | | `referrer` | `string` | no | ≤ 2048 chars | ### `screen` The visitor moved to another screen (native SDKs); replaces `page` and is recorded the same way. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `name` | `string` | yes | 1–200 chars | | `params` | `object` | no | key ≤ 60, value ≤ 500 chars | ### `app.state` The app went to the background or came back. A backgrounded app counts as offline: replies go out by push notification or email; the conversation on screen (`conversation_id`) gets no push. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `foreground` | `boolean` | yes | | | `conversation_id` | `string \| null` | no | 1–64 chars | ### `message.send` Send a message. `client_id` is generated by the client and reused on retry; the server de-duplicates. An empty `content` needs at least one `attachments` item. Limited to 5 messages / 10 seconds and 60 / minute. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `conversation_id` | `string` | no | 1–64 chars | | `client_id` | `string` | yes | 1–64 chars | | `content` | `string` | yes | ≤ 4000 chars | | `attachments` | `string[]` | no | ≤ 5 items; each item ≤ 64 chars | ### `typing` The visitor is typing. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `conversation_id` | `string` | yes | 1–64 chars | | `on` | `boolean` | yes | | ### `read` The visitor has read up to `up_to_seq`; send only while the conversation is visible and the app is in the foreground. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `conversation_id` | `string` | yes | 1–64 chars | | `up_to_seq` | `integer` | yes | ≥ 0; ≤ 2147483647 | ### `identify` Attach an identity. `hash` is the hex HMAC-SHA256 of `user_id` with the site's identity secret (see [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md)). A signed identity that already belongs to another visitor is merged and answered with a `session` frame. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `user_id` | `string` | no | ≤ 200 chars | | `email` | `string` | no | ≤ 320 chars; matches `^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$`; email | | `name` | `string` | no | ≤ 200 chars | | `phone` | `string` | no | ≤ 40 chars | | `hash` | `string` | no | ≤ 128 chars | | `data` | `object` | no | | ### `event` Any custom event from the app (`pushEvent`), recorded on the active conversation. Before the visitor has a conversation, pages, screens and events are kept as their journey (the last 20, for 24 hours) and attached to the conversation they start, for agents only. Values under secret-looking keys (password, token, secret, card, cvv, otp) are replaced with `[redacted]`. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `name` | `string` | yes | ≤ 100 chars | | `data` | `object` | no | | ### `resume` Replay the items after `after_seq` of one of the visitor's conversations, after reconnecting. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `conversation_id` | `string` | yes | 1–64 chars | | `after_seq` | `integer` | yes | ≥ 0; ≤ 2147483647 | ### `rate` Rate a resolved conversation (1–5). `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `conversation_id` | `string` | yes | 1–64 chars | | `score` | `integer` | yes | ≥ 1; ≤ 5 | | `comment` | `string` | no | ≤ 1000 chars | ### `campaign` Interaction with a campaign (shown, clicked, opened, form sent). `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `campaign_id` | `string` | yes | 1–64 chars | | `action` | `"shown" \| "clicked" \| "open" \| "submit"` | yes | | | `button` | `string` | no | ≤ 40 chars | | `fields` | `object {name?, phone?, email?}` | no | | ### `ping` Keep-alive every 25 seconds; the server answers `pong`. `id`: optional (matched against `message.ack` / `error`). _No data (`d` is `null`)._ ## Server → visitor frames ### `ready` Authenticated: the visitor, the open conversation (or `null`), the latest 100 items and the team's availability. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `visitor` | `Visitor` ([Visitor](#visitor)) | yes | | | `conversation` | `Conversation \| null` ([Conversation](#conversation)) | yes | | | `timeline` | `(Message \| Event)[]` ([Message](#message), [Event](#event)) | yes | | | `timeline_has_more` | `boolean` | no | | | `availability` | `Availability` ([Availability](#availability)) | yes | | ### `message` A new message in a conversation (including the echo of the visitor's own; de-duplicate by `id`). `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `id` | `string` | yes | 1–64 chars | | `conversation_id` | `string` | yes | 1–64 chars | | `seq` | `integer` | yes | | | `sender_type` | `"visitor" \| "agent" \| "bot" \| "system"` | yes | | | `sender_id` | `string \| null` | yes | | | `sender_name` | `string \| null` | no | | | `visibility` | `"public" \| "private"` | yes | | | `content_type` | `"text" \| "file"` | yes | | | `content` | `string` | yes | | | `attachments` | `Attachment[]` ([Attachment](#attachment)) | yes | | | `client_id` | `string \| null` | no | | | `metadata` | `object` | no | | | `delivery_status` | `"pending" \| "sent" \| "failed" \| null` | no | | | `delivery_error` | `string \| null` | no | | | `read_at` | `string \| null` | no | | | `created_at` | `string` | yes | | ### `message.ack` Confirms a `message.send` with the real `id`, `seq` and `conversation_id`. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `client_id` | `string` | yes | | | `id` | `string` | yes | 1–64 chars | | `seq` | `integer` | yes | | | `conversation_id` | `string` | yes | 1–64 chars | ### `event` An event in a conversation. Visitors only get `status_changed`, `handoff`, `bot_resumed`, `rated`, `order_paid`, `ticket_created`, `ticket_status` and `notice`; ignore unknown types. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `id` | `string` | yes | 1–64 chars | | `conversation_id` | `string` | yes | 1–64 chars | | `seq` | `integer` | yes | | | `type` | `string` | yes | | | `actor_type` | `"visitor" \| "agent" \| "bot" \| "system"` | yes | | | `actor_id` | `string \| null` | yes | | | `data` | `object` | yes | | | `created_at` | `string` | yes | | ### `typing` An agent is typing. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `conversation_id` | `string` | yes | 1–64 chars | | `side` | `"visitor" \| "agent"` | yes | | | `on` | `boolean` | yes | | | `name` | `string` | no | | ### `availability` Whether the team is online and within business hours, when it reopens and how many agents are online. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `online` | `boolean` | yes | | | `within_hours` | `boolean` | yes | | | `next_open_at` | `string \| null` | yes | | | `agents_online` | `integer` | yes | | ### `read` The other side has read up to `up_to_seq`. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `conversation_id` | `string` | yes | 1–64 chars | | `side` | `"visitor" \| "agent"` | yes | | | `up_to_seq` | `integer` | yes | | ### `conversation.update` One of the visitor's conversations changed status, unread count or assignee (a visitor-safe view). `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `id` | `string` | yes | 1–64 chars | | `site_id` | `string` | yes | 1–64 chars | | `status` | `"open" \| "pending" \| "resolved" \| "closed"` | yes | | | `seq` | `integer` | yes | | | `visitor_unread` | `integer` | yes | | | `last_message_at` | `string \| null` | yes | | | `last_message_preview` | `string \| null` | yes | | | `last_message_sender_type` | `"visitor" \| "agent" \| "bot" \| "system" \| null` | yes | | | `first_response_at` | `string \| null` | yes | | | `resolved_at` | `string \| null` | yes | | | `subject` | `string \| null` | yes | | | `created_at` | `string` | yes | | | `updated_at` | `string` | yes | | | `agent` | `object {name, avatar_url} \| null` | yes | | ### `resume` Answer to `resume`: the items after `after_seq`, with `has_more` when there are more. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `conversation_id` | `string` | yes | 1–64 chars | | `items` | `(Message \| Event)[]` ([Message](#message), [Event](#event)) | yes | | | `has_more` | `boolean` | yes | | ### `conversation.start` The team messaged first; `open` says whether to open the chat screen right away. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `conversation` | `Conversation` ([Conversation](#conversation)) | yes | | | `open` | `boolean` | yes | | ### `session` The session changed owner (identity merge or reset): store the new `visitor_token` and bootstrap again. Web sockets get `reload` right after; app sockets are closed with code 4005. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `visitor_token` | `string` | yes | | | `visitor_id` | `string` | yes | 1–64 chars | | `reason` | `"identity_merged" \| "reset"` | yes | | ### `identified` `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `visitor_id` | `string` | yes | 1–64 chars | | `verified` | `boolean` | yes | | ### `reload` Web clients reload the page; native clients bootstrap again (with close code 4004 they stop reconnecting and ask for an app update). `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `reason` | `string` | yes | | ### `error` An error for one request (`for` is the `id` of the frame that caused it) or a general error. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `code` | `string` | yes | | | `message` | `string` | yes | | | `for` | `string` | no | | ### `pong` Answer to `ping`. `id`: optional (matched against `message.ack` / `error`). | Field | Type | Required | Constraints | |---|---|---|---| | `now` | `string` | yes | | ## HTTP ### `POST bootstrap` response {#bootstrap} | Field | Type | Required | Constraints | |---|---|---|---| | `visitor_token` | `string` | yes | | | `visitor` | `Visitor` ([Visitor](#visitor)) | yes | | | `config` | `object {site_id, name, color, position, locale_default, greeting, away_message, offline_capture, hide_when_offline, theme, bot_enabled, agent_avatars, launcher?, header?, greeting_options?, contact_buttons?, layout?, prechat?, visibility?, notifications?, allow_in_iframe?}` | yes | | | `availability` | `Availability` ([Availability](#availability)) | yes | | | `conversation` | `Conversation \| null` ([Conversation](#conversation)) | yes | | | `timeline` | `(Message \| Event)[]` ([Message](#message), [Event](#event)) | yes | | | `timeline_has_more` | `boolean` | no | | | `bundle` | `string` | yes | | | `ws_url` | `string` | yes | | | `protocol_version` | `integer` | yes | | | `app` | `object {id, platform} \| null` | yes | | ### Cards in a message's `metadata` {#cards} A message whose `metadata.kind` is listed below carries structured data; `content` is always plain text so email, previews and channels without a template can still read it. Unknown kinds render as plain messages. #### `product` | Field | Type | Required | Constraints | |---|---|---|---| | `kind` | `"product"` | yes | | | `products` | `(object {id, name, price, compare_at_price?, currency, image_url?, url?, sku?, store_product_ids?, base_plan_id?, offer_id?, billing_period?, trial_days?})[]` | yes | | | `intro` | `string` | no | | #### `order` | Field | Type | Required | Constraints | |---|---|---|---| | `kind` | `"order"` | yes | | | `order` | `object {id, code, status, items, subtotal, discount, shipping, total, currency, payment_status}` | yes | | | `intro` | `string` | no | | #### `payment` | Field | Type | Required | Constraints | |---|---|---|---| | `kind` | `"payment"` | yes | | | `payment` | `object {id, order_id, order_code, amount, currency, checkout_url, qr_image_url, qr_payload?, code, expires_at, status}` | yes | | | `intro` | `string` | no | | #### `quick_replies` | Field | Type | Required | Constraints | |---|---|---|---| | `kind` | `"quick_replies"` | yes | | | `options` | `string[]` | yes | | #### `csat` | Field | Type | Required | Constraints | |---|---|---|---| | `kind` | `"csat"` | yes | | | `options` | `string[]` | yes | | | `answered` | `boolean` | no | | ## Shared types Objects that appear in several frames. In `ready.timeline` and `resume.items` every item also has `kind: "message" | "event"`. ### Visitor {#visitor} | Field | Type | Required | Constraints | |---|---|---|---| | `id` | `string` | yes | 1–64 chars | | `site_id` | `string` | yes | 1–64 chars | | `channel` | `string` | yes | | | `email` | `string \| null` | yes | | | `name` | `string \| null` | yes | | | `phone` | `string \| null` | yes | | | `avatar_url` | `string \| null` | yes | | | `external_id` | `string \| null` | yes | | | `custom_data` | `object` | yes | | | `locale` | `"vi" \| "en" \| null` | yes | | | `browser` | `string \| null` | yes | | | `os` | `string \| null` | yes | | | `device` | `string \| null` | yes | | | `country` | `string \| null` | yes | | | `city` | `string \| null` | yes | | | `current_url` | `string \| null` | yes | | | `current_title` | `string \| null` | yes | | | `referrer` | `string \| null` | yes | | | `first_seen_at` | `string` | yes | | | `last_seen_at` | `string` | yes | | | `identified_at` | `string \| null` | yes | | | `utm` | `object` | yes | | | `visits` | `integer` | yes | | | `customer_id` | `string \| null` | yes | | | `client` | `"web" \| "app"` | yes | | | `app` | `object {id, platform, version, sdk} \| null` | no | | | `current_screen` | `string \| null` | no | | ### Conversation {#conversation} | Field | Type | Required | Constraints | |---|---|---|---| | `id` | `string` | yes | 1–64 chars | | `site_id` | `string` | yes | 1–64 chars | | `visitor_id` | `string` | yes | 1–64 chars | | `channel` | `string` | yes | | | `channel_account_id` | `string \| null` | no | | | `status` | `"open" \| "pending" \| "resolved" \| "closed"` | yes | | | `assignee_kind` | `"user" \| "bot" \| null` | yes | | | `assignee_id` | `string \| null` | yes | | | `seq` | `integer` | yes | | | `visitor_unread` | `integer` | yes | | | `agent_unread` | `integer` | yes | | | `last_message_at` | `string \| null` | yes | | | `last_message_preview` | `string \| null` | yes | | | `last_message_sender_type` | `"visitor" \| "agent" \| "bot" \| "system" \| null` | yes | | | `last_visitor_message_at` | `string \| null` | no | | | `first_response_at` | `string \| null` | yes | | | `resolved_at` | `string \| null` | yes | | | `tags` | `string[]` | yes | | | `member_ids` | `string[]` | yes | | | `flag` | `"hidden" \| "spam" \| null` | yes | | | `snoozed_until` | `string` | no | | | `muted_by` | `string[]` | no | | | `subject` | `string \| null` | yes | | | `created_at` | `string` | yes | | | `updated_at` | `string` | yes | | ### Message {#message} | Field | Type | Required | Constraints | |---|---|---|---| | `id` | `string` | yes | 1–64 chars | | `conversation_id` | `string` | yes | 1–64 chars | | `seq` | `integer` | yes | | | `sender_type` | `"visitor" \| "agent" \| "bot" \| "system"` | yes | | | `sender_id` | `string \| null` | yes | | | `sender_name` | `string \| null` | no | | | `visibility` | `"public" \| "private"` | yes | | | `content_type` | `"text" \| "file"` | yes | | | `content` | `string` | yes | | | `attachments` | `Attachment[]` ([Attachment](#attachment)) | yes | | | `client_id` | `string \| null` | no | | | `metadata` | `object` | no | | | `delivery_status` | `"pending" \| "sent" \| "failed" \| null` | no | | | `delivery_error` | `string \| null` | no | | | `read_at` | `string \| null` | no | | | `created_at` | `string` | yes | | | `kind` | `"message"` | yes | | ### Event {#event} | Field | Type | Required | Constraints | |---|---|---|---| | `id` | `string` | yes | 1–64 chars | | `conversation_id` | `string` | yes | 1–64 chars | | `seq` | `integer` | yes | | | `type` | `string` | yes | | | `actor_type` | `"visitor" \| "agent" \| "bot" \| "system"` | yes | | | `actor_id` | `string \| null` | yes | | | `data` | `object` | yes | | | `created_at` | `string` | yes | | | `kind` | `"event"` | yes | | ### Attachment {#attachment} | Field | Type | Required | Constraints | |---|---|---|---| | `id` | `string` | yes | 1–64 chars | | `name` | `string` | yes | | | `size` | `integer` | yes | | | `type` | `string` | yes | | | `url` | `string` | no | | ### Availability {#availability} | Field | Type | Required | Constraints | |---|---|---|---| | `online` | `boolean` | yes | | | `within_hours` | `boolean` | yes | | | `next_open_at` | `string \| null` | yes | | | `agents_online` | `integer` | yes | | Source: https://hatang.dev/docs/reference/realtime-protocol --- # Webhook events Ha Tang sends one HTTP `POST` per event to each webhook endpoint subscribed to it. The body is always the same envelope; `data` depends on the event. Each event below links to its full payload schema and an example. ## Envelope ```json { "type": "email.delivered", "created_at": "2026-10-23T08:15:00.000Z", "data": { "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", "from": "Shop ", "to": [ "an@example.com" ], "subject": "Your order", "created_at": "2026-10-23T08:14:58.000Z" } } ``` ## Headers | Header | Meaning | |---|---| | `svix-id` / `webhook-id` | Unique id of the delivery. The same id is reused for retries and replays: de-duplicate on it. | | `svix-timestamp` / `webhook-timestamp` | Unix seconds when the delivery was signed. Reject anything more than 5 minutes off. | | `svix-signature` / `webhook-signature` | `v1,` signatures, space-separated (two during a secret rotation). | Answer with any 2xx within 15 seconds. Other answers and timeouts are retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. You can replay any delivery from the dashboard or with `POST /webhooks/deliveries/{id}/replay`. Set-up, signature verification code and testing are in the [Webhooks guide](https://hatang.dev/docs/guides/getting-started/webhooks.md). ## Email events | Event | When it fires | |---|---| | [`email.sent`](https://hatang.dev/docs/reference/api/webhook-email-sent) | The email was accepted by the sending infrastructure. | | [`email.delivered`](https://hatang.dev/docs/reference/api/webhook-email-delivered) | The recipient's mail server accepted the email. | | [`email.delivery_delayed`](https://hatang.dev/docs/reference/api/webhook-email-delivery-delayed) | Delivery is being retried (for example the recipient's server is temporarily refusing mail). | | [`email.bounced`](https://hatang.dev/docs/reference/api/webhook-email-bounced) | The email bounced. `data.bounce.type` is `Permanent` (the address is suppressed) or `Transient`. | | [`email.complained`](https://hatang.dev/docs/reference/api/webhook-email-complained) | The recipient marked the email as spam. The address is suppressed. | | [`email.opened`](https://hatang.dev/docs/reference/api/webhook-email-opened) | The recipient opened the email (open tracking on). Fired once per open. | | [`email.clicked`](https://hatang.dev/docs/reference/api/webhook-email-clicked) | The recipient clicked a tracked link. `data.click.link` is the original URL. | | [`email.failed`](https://hatang.dev/docs/reference/api/webhook-email-failed) | The email could not be sent. `data.failed.reason` says why. | | [`email.received`](https://hatang.dev/docs/reference/api/webhook-email-received) | An email arrived at one of your receiving domains. Fetch the body and attachments with the Receiving API. | | [`contact.created`](https://hatang.dev/docs/reference/api/webhook-contact-created) | A contact was added (API, dashboard, import or CRM sync). | | [`contact.updated`](https://hatang.dev/docs/reference/api/webhook-contact-updated) | A contact's name, properties or subscription changed. | | [`contact.deleted`](https://hatang.dev/docs/reference/api/webhook-contact-deleted) | A contact was deleted. | | [`domain.created`](https://hatang.dev/docs/reference/api/webhook-domain-created) | A sending domain was added. | | [`domain.updated`](https://hatang.dev/docs/reference/api/webhook-domain-updated) | A domain changed, including its verification status. | | [`domain.deleted`](https://hatang.dev/docs/reference/api/webhook-domain-deleted) | A domain was removed. | ## Support events | Event | When it fires | |---|---| | [`chat.conversation.created`](https://hatang.dev/docs/reference/api/webhook-chat-conversation-created) | A new conversation started on any channel. | | [`chat.conversation.assigned`](https://hatang.dev/docs/reference/api/webhook-chat-conversation-assigned) | A conversation was assigned (or unassigned). | | [`chat.conversation.resolved`](https://hatang.dev/docs/reference/api/webhook-chat-conversation-resolved) | A conversation was marked resolved. | | [`chat.conversation.closed`](https://hatang.dev/docs/reference/api/webhook-chat-conversation-closed) | A conversation was closed. | | [`chat.conversation.reopened`](https://hatang.dev/docs/reference/api/webhook-chat-conversation-reopened) | A resolved or closed conversation was reopened. | | [`chat.conversation.handoff`](https://hatang.dev/docs/reference/api/webhook-chat-conversation-handoff) | The AI assistant handed the conversation to your team. | | [`chat.conversation.tagged`](https://hatang.dev/docs/reference/api/webhook-chat-conversation-tagged) | A tag was added to a conversation; one event per added tag. | | [`chat.message.created`](https://hatang.dev/docs/reference/api/webhook-chat-message-created) | A public message was posted by the customer, an agent, the AI assistant or a flow. Internal notes do not fire it. | | [`chat.visitor.identified`](https://hatang.dev/docs/reference/api/webhook-chat-visitor-identified) | A visitor was identified (`identify` from the widget or an SDK). | | [`chat.order.created`](https://hatang.dev/docs/reference/api/webhook-chat-order-created) | An order was created from the inbox or the API. | | [`chat.order.updated`](https://hatang.dev/docs/reference/api/webhook-chat-order-updated) | An order changed (status, items, shipping). | | [`chat.order.paid`](https://hatang.dev/docs/reference/api/webhook-chat-order-paid) | An order was paid. | | [`chat.flow.webhook`](https://hatang.dev/docs/reference/api/webhook-chat-flow-webhook) | A flow or workflow reached a Webhook step. | | [`chat.campaign.lead`](https://hatang.dev/docs/reference/api/webhook-chat-campaign-lead) | A visitor submitted a lead form. | | [`chat.ticket.created`](https://hatang.dev/docs/reference/api/webhook-chat-ticket-created) | A support ticket was created. | | [`chat.ticket.updated`](https://hatang.dev/docs/reference/api/webhook-chat-ticket-updated) | A ticket changed. | | [`chat.ticket.resolved`](https://hatang.dev/docs/reference/api/webhook-chat-ticket-resolved) | A ticket was resolved. | | [`chat.ticket.breached`](https://hatang.dev/docs/reference/api/webhook-chat-ticket-breached) | A ticket missed an SLA target. `clock` says which one. | | [`chat.rating.created`](https://hatang.dev/docs/reference/api/webhook-chat-rating-created) | A customer rated a conversation (CSAT). | | [`chat.zns.sent`](https://hatang.dev/docs/reference/api/webhook-chat-zns-sent) | A Zalo ZNS message was delivered to Zalo. | | [`chat.zns.failed`](https://hatang.dev/docs/reference/api/webhook-chat-zns-failed) | A Zalo ZNS message failed. `error` has the reason. | ## Payments events | Event | When it fires | |---|---| | [`payment.created`](https://hatang.dev/docs/reference/api/webhook-payment-created) | A payment request was created. | | [`payment.succeeded`](https://hatang.dev/docs/reference/api/webhook-payment-succeeded) | The full amount arrived. | | [`payment.partially_paid`](https://hatang.dev/docs/reference/api/webhook-payment-partially-paid) | Less than the full amount arrived. | | [`payment.overpaid`](https://hatang.dev/docs/reference/api/webhook-payment-overpaid) | More than the requested amount arrived. | | [`payment.expired`](https://hatang.dev/docs/reference/api/webhook-payment-expired) | The payment expired before it was paid. | | [`payment.canceled`](https://hatang.dev/docs/reference/api/webhook-payment-canceled) | The payment was canceled. | | [`transaction.received`](https://hatang.dev/docs/reference/api/webhook-transaction-received) | Money arrived in a linked bank account (matched or not). | | [`bank_connection.activated`](https://hatang.dev/docs/reference/api/webhook-bank-connection-activated) | A bank account was linked and confirmed. | | [`bank_connection.revoked`](https://hatang.dev/docs/reference/api/webhook-bank-connection-revoked) | A bank link was removed. | | [`bank_connection.needs_reauth`](https://hatang.dev/docs/reference/api/webhook-bank-connection-needs-reauth) | The bank needs the account holder to confirm the link again. | | [`invoice.draft_created`](https://hatang.dev/docs/reference/api/webhook-invoice-draft-created) | A draft e-invoice was created. | | [`invoice.draft_updated`](https://hatang.dev/docs/reference/api/webhook-invoice-draft-updated) | A draft e-invoice changed. | | [`invoice.draft_deleted`](https://hatang.dev/docs/reference/api/webhook-invoice-draft-deleted) | A draft e-invoice was deleted. | | [`invoice.issued`](https://hatang.dev/docs/reference/api/webhook-invoice-issued) | An e-invoice was issued by the provider. | | [`invoice.tax_accepted`](https://hatang.dev/docs/reference/api/webhook-invoice-tax-accepted) | The tax authority accepted the e-invoice. | | [`invoice.tax_rejected`](https://hatang.dev/docs/reference/api/webhook-invoice-tax-rejected) | The tax authority rejected the e-invoice. | | [`invoice.canceled`](https://hatang.dev/docs/reference/api/webhook-invoice-canceled) | An e-invoice was canceled. | | [`invoice.replaced`](https://hatang.dev/docs/reference/api/webhook-invoice-replaced) | An e-invoice was replaced by a new one. | | [`invoice.adjusted`](https://hatang.dev/docs/reference/api/webhook-invoice-adjusted) | An adjustment invoice was issued. | | [`invoice.failed`](https://hatang.dev/docs/reference/api/webhook-invoice-failed) | Issuing an e-invoice failed. | | [`invoice.conflict`](https://hatang.dev/docs/reference/api/webhook-invoice-conflict) | The invoice changed on the provider's side and conflicts with Ha Tang's copy. | Source: https://hatang.dev/docs/reference/webhooks --- # REST API endpoints The Ha Tang REST API: email, support (chat) and payments. Authenticate with `Authorization: Bearer ht_...`. Every error uses the `{statusCode, name, message}` envelope. Send `Idempotency-Key` on `POST /emails` to make retries safe. Base URL: https://api.hatang.dev ## Emails - `POST /emails`: Send an email - `GET /emails`: List emails - `POST /emails/batch`: Send up to 100 emails in one call - `GET /emails/{id}`: Retrieve an email - `PATCH /emails/{id}`: Reschedule a scheduled email - `GET /emails/{id}/events`: Delivery events and per-recipient status - `POST /emails/{id}/cancel`: Cancel a scheduled email ## Receiving - `GET /emails/receiving`: List received emails (domains with receiving on; needs email:read) - `GET /emails/receiving/{id}`: Retrieve a received email: text, HTML, headers and attachment metadata - `GET /emails/receiving/{id}/attachments`: List a received email's attachments with download URLs (valid for an hour) - `GET /emails/receiving/{id}/attachments/{attachment_id}`: Retrieve one attachment with a download URL - `GET /files/received/{attachment_id}`: Download a received attachment (the signed download_url; no API key) ## Domains - `POST /domains`: Add a domain - `GET /domains`: List domains - `GET /domains/{id}`: Retrieve a domain - `PATCH /domains/{id}`: Update tracking or TLS settings - `DELETE /domains/{id}`: Delete a domain - `POST /domains/{id}/verify`: Check the DNS records now and keep re-checking - `GET /domains/{id}/dns-provider`: Detect the DNS host (Cloudflare or other) and the zone holding the records - `GET /domains/{id}/domain-connect`: Is one-click DNS setup (Domain Connect) available at this domain's DNS host? - `POST /domains/{id}/domain-connect`: Start Domain Connect: get the signed URL to send the user to their DNS host ## API Keys - `POST /api-keys`: Create an API key - `GET /api-keys`: List API keys - `DELETE /api-keys/{id}`: Revoke an API key ## Webhooks - `POST /webhooks`: Create a webhook endpoint - `GET /webhooks`: List webhooks - `GET /webhooks/{id}`: Retrieve a webhook - `PATCH /webhooks/{id}`: Update a webhook - `DELETE /webhooks/{id}`: Delete a webhook - `POST /webhooks/{id}/rotate`: Rotate the signing secret (old secret stays valid 24h) - `GET /webhooks/{id}/deliveries`: Recent delivery attempts - `POST /webhooks/deliveries/{deliveryId}/replay`: Re-send a delivery with the same svix-id ## Audiences - `POST /audiences`: Create an audience - `GET /audiences`: List audiences - `GET /audiences/{id}`: Retrieve an audience - `DELETE /audiences/{id}`: Delete an audience and its contacts ## Contacts - `POST /audiences/{id}/contacts`: Create or update a contact - `GET /audiences/{id}/contacts`: List contacts - `GET /audiences/{id}/contacts/{contact}`: Retrieve a contact by id or email - `PATCH /audiences/{id}/contacts/{contact}`: Update a contact - `DELETE /audiences/{id}/contacts/{contact}`: Delete a contact - `POST /contacts`: Create a contact (or update the one with that email) - `GET /contacts`: List contacts - `GET /contacts/{id}`: Retrieve a contact by id or email - `PATCH /contacts/{id}`: Update a contact - `DELETE /contacts/{id}`: Delete a contact - `GET /contacts/{id}/segments`: Segments the contact belongs to - `POST /contacts/{id}/segments/{segment}`: Add the contact to a segment - `DELETE /contacts/{id}/segments/{segment}`: Remove the contact from a segment - `GET /contacts/{id}/topics`: The contact's subscription to every topic - `PATCH /contacts/{id}/topics`: Set the contact's topic subscriptions ## Broadcasts - `POST /broadcasts`: Create a broadcast draft - `GET /broadcasts`: List broadcasts - `GET /broadcasts/{id}`: Retrieve a broadcast - `PATCH /broadcasts/{id}`: Update a draft or scheduled broadcast - `DELETE /broadcasts/{id}`: Delete a broadcast - `POST /broadcasts/{id}/send`: Send now or schedule - `POST /broadcasts/{id}/cancel`: Cancel a scheduled broadcast ## Templates - `POST /templates`: Create a template (draft) - `GET /templates`: List templates - `GET /templates/{id}`: Retrieve a template by id or alias - `PATCH /templates/{id}`: Update the draft of a template - `DELETE /templates/{id}`: Delete a template - `POST /templates/{id}/publish`: Publish the draft; sends use the published version - `POST /templates/{id}/duplicate`: Copy a template as a new draft ## Metrics - `GET /emails/metrics`: Email metrics per period (and domain): counts and rates ## Logs - `GET /logs`: API request logs, newest first - `GET /logs/{id}`: One request log with its request and response bodies (secrets redacted) ## Usage - `GET /usage`: What the business used against its limits ## Segments - `POST /segments`: Create a segment - `GET /segments`: List segments - `GET /segments/{id}`: Retrieve a segment with its contact count - `PATCH /segments/{id}`: Rename a segment - `DELETE /segments/{id}`: Delete a segment (its contacts stay) - `GET /segments/{id}/contacts`: Contacts in a segment ## Topics - `POST /topics`: Create a topic - `GET /topics`: List topics - `GET /topics/{id}`: Retrieve a topic - `PATCH /topics/{id}`: Update a topic's name, description or visibility - `DELETE /topics/{id}`: Delete a topic ## Contact properties - `POST /contact-properties`: Declare a contact property - `GET /contact-properties`: List contact properties - `PATCH /contact-properties/{id}`: Change a property's fallback value - `DELETE /contact-properties/{id}`: Delete a property and its values ## Events - `POST /events`: Define a custom event (name and payload schema) - `GET /events`: List event definitions - `POST /events/send`: Send an event for a contact: starts automations triggered by it and resumes runs waiting for it - `GET /events/{id}`: Retrieve an event definition by id or name - `PATCH /events/{id}`: Change an event's payload schema - `DELETE /events/{id}`: Delete an event definition ## Automations - `POST /automations`: Create an automation (steps and connections) - `GET /automations`: List automations - `GET /automations/{id}`: Retrieve an automation - `PATCH /automations/{id}`: Update an automation; steps can only change while it is disabled - `DELETE /automations/{id}`: Delete an automation and its runs - `POST /automations/{id}/stop`: Disable the automation and cancel its runs in flight - `POST /automations/{id}/duplicate`: Copy an automation as a new disabled one - `GET /automations/{id}/runs`: Runs of an automation - `GET /automations/{id}/runs/{run_id}`: One run with its steps ## Chat sites - `POST /chat/sites`: Add an extra chat site on a subdomain (the domain's own widget is created automatically) - `GET /chat/sites`: List chat sites - `GET /chat/sites/{id}`: Retrieve a site - `PATCH /chat/sites/{id}`: Update a site's settings, origins or status - `DELETE /chat/sites/{id}`: Delete a site and everything under it - `POST /chat/sites/{id}/identity-secret`: Rotate the secret used to sign identify() calls - `GET /chat/profile-links`: The business's profile links (URL templates agents open for the person in a conversation) - `PUT /chat/profile-links`: Replace the profile links (max 10; https, known {{variables}}, none in the host) - `GET /chat/sites/{id}/kb/sources`: List the business's knowledge sources - `POST /chat/sites/{id}/kb/sources`: Add a knowledge source (FAQ, text or website crawl); indexing runs in the background - `GET /chat/sites/{id}/kb/sources/{sourceId}`: Get a knowledge source - `PATCH /chat/sites/{id}/kb/sources/{sourceId}`: Update a knowledge source and re-index it - `DELETE /chat/sites/{id}/kb/sources/{sourceId}`: Delete a knowledge source and its chunks - `POST /chat/sites/{id}/kb/sources/{sourceId}/reindex`: Re-index a knowledge source - `POST /chat/sites/{id}/kb/files`: Upload a PDF or DOCX as a knowledge source (multipart: file, optional title). Text is extracted on upload, up to 20 MB; scanned PDFs need OCR first. - `GET /chat/sites/{id}/kb/search`: Search the knowledge base the assistant answers from (keyword and semantic matches, fused by rank) - `GET /chat/subscriptions`: List subscriptions (web, App Store, Google Play); filter by external_user_id, platform, status (comma-separated) - `PUT /chat/subscriptions/{platform}/{original_id}`: Create or update a subscription from your backend (App Store and Google Play ones also arrive from the stores once connected) - `POST /chat/subscriptions/{id}/refresh`: Ask the App Store / Google Play about one subscription again (needs the app's App Store Server API key or Play service account) - `POST /chat/subscriptions/import`: Import existing subscribers of an app: Apple transaction ids (any of the customer's; their one-time purchases come too) or Google Play purchase tokens, up to 500 per call - `GET /chat/sites/{id}/purchase-token`: The app account token of one of your users: pass it as Apple appAccountToken / Google obfuscatedExternalAccountId when buying ## Conversations - `GET /chat/conversations`: List conversations - `GET /chat/conversations/{id}`: Retrieve a conversation - `PATCH /chat/conversations/{id}`: Assign, change status or replace tags - `GET /chat/conversations/{id}/messages`: Timeline (messages and events) of a conversation - `POST /chat/conversations/{id}/messages`: Send a message as your team (Idempotency-Key supported) - `POST /chat/conversations/{id}/products`: Send product cards into a conversation - `POST /chat/conversations/{id}/flows/{flowId}`: Start a flow in a conversation right now - `POST /chat/conversations/{id}/workflows/{workflowId}`: Run a workflow for a conversation now (its trigger conditions are skipped) - `POST /chat/conversations/{id}/workflows/stop`: Stop every running or waiting workflow run in a conversation ## Customers - `GET /chat/customers`: List customers (one person across channels) - `GET /chat/customers/export.csv`: Export the matching customers as CSV - `GET /chat/customers/merge-suggestions`: Pairs of customers that share an e-mail or phone number - `GET /chat/customers/{id}`: Retrieve a customer with their visitors - `PATCH /chat/customers/{id}`: Update contact details, owner, stage, labels or custom fields - `POST /chat/customers/{id}/merge`: Merge another customer into this one - `GET /chat/customers/{id}/conversations`: A customer's conversations across all channels - `GET /chat/customer-fields`: List custom customer fields - `POST /chat/customer-fields`: Create a custom customer field - `PATCH /chat/customer-fields/{id}`: Update a custom customer field - `DELETE /chat/customer-fields/{id}`: Delete a custom customer field - `GET /chat/customers/{id}/subscriptions`: A customer's subscriptions, newest first - `GET /chat/customers/{id}/purchases`: A customer's one-time in-app purchases (consumable, non-consumable, non-renewing), verified with the App Store or Google Play, newest first ## Products - `GET /chat/products`: List catalog products - `POST /chat/products`: Add a product - `GET /chat/products/search`: Relevance search over the catalog (accent-insensitive) - `GET /chat/products/categories`: Distinct product categories - `POST /chat/products/import`: Import products from CSV text, a CSV/Google Sheets link, or a website's schema.org Product JSON-LD - `GET /chat/products/{id}`: Retrieve a product - `PATCH /chat/products/{id}`: Update a product - `DELETE /chat/products/{id}`: Delete a product ## Orders - `GET /chat/orders`: List orders - `POST /chat/orders`: Create an order (optionally from a conversation, posting an order card) - `GET /chat/orders/stats`: Orders per stage and paid revenue - `GET /chat/orders/{id}`: Retrieve an order by id or code - `PATCH /chat/orders/{id}`: Update items, stage, address or note - `POST /chat/orders/{id}/payment-link`: Create a VietQR payment for the order and post it into the conversation ## ZNS - `GET /chat/zns/templates`: Approved ZNS templates of a Zalo OA account (with the daily quota) - `GET /chat/zns/templates/{id}`: A ZNS template with its parameters - `GET /chat/zns`: ZNS messages sent by the team - `POST /chat/zns`: Send one ZNS template message to a phone number (billed by Zalo) - `POST /chat/zns/bulk`: Queue a ZNS template to every customer matching the filters (one message per customer with a phone) ## Reports and ratings - `GET /chat/reports`: Support report for a date range: overview, per-day, per-agent, channels, tags (defaults to the last 30 days) - `GET /chat/ratings`: CSAT ratings in a date range ## Tickets - `GET /chat/tickets`: List tickets - `POST /chat/tickets`: Create a ticket (SLA deadlines follow the team policy) - `GET /chat/tickets/stats`: Open, overdue, unassigned counts and this week's resolution figures - `GET /chat/tickets/{id}`: Retrieve a ticket by id or code (TK000123) - `PATCH /chat/tickets/{id}`: Update a ticket (status, priority, assignee, due date…) - `DELETE /chat/tickets/{id}`: Delete a ticket - `GET /chat/sla`: The team's SLA policy (minutes per priority) - `PUT /chat/sla`: Replace the SLA policy ## Flows - `GET /chat/flows`: List scripted flows - `POST /chat/flows`: Create a scripted flow (trigger + steps) - `GET /chat/flows/templates`: Ready-made flow templates - `GET /chat/flows/{id}`: Retrieve a flow - `PATCH /chat/flows/{id}`: Replace a flow's definition - `DELETE /chat/flows/{id}`: Delete a flow ## Workflows - `GET /chat/workflows`: List workflows (event-driven automation) - `POST /chat/workflows`: Create a workflow: a trigger event, optional conditions and a list of steps - `GET /chat/workflows/templates`: Ready-made workflow templates - `GET /chat/workflows/{id}`: Retrieve a workflow - `PATCH /chat/workflows/{id}`: Replace a workflow's definition - `DELETE /chat/workflows/{id}`: Delete a workflow - `GET /chat/workflows/{id}/runs`: Recent runs of a workflow, with the log of each step ## Campaigns - `GET /chat/campaigns`: List widget campaigns - `POST /chat/campaigns`: Create a campaign (proactive message or lead form) - `GET /chat/campaigns/{id}`: Retrieve a campaign - `PATCH /chat/campaigns/{id}`: Update a campaign - `DELETE /chat/campaigns/{id}`: Delete a campaign ## Visitors - `GET /chat/live`: Visitors currently on your sites - `POST /chat/live/greet`: Start a conversation with a visitor who has not written yet - `GET /chat/visitors/{id}`: Retrieve a visitor - `PATCH /chat/visitors/{id}`: Update a visitor's identity or custom data - `DELETE /chat/visitors/{id}`: Anonymise a visitor (GDPR-style delete) - `GET /chat/visitors/{id}/conversations`: A visitor's conversations ## Knowledge - `GET /chat/knowledge/documents`: List knowledge documents, in id order - `POST /chat/knowledge/documents/batch`: Create or update up to 100 documents - `GET /chat/knowledge/documents/{id}`: Get a knowledge document - `PUT /chat/knowledge/documents/{id}`: Create or update a knowledge document under your own id; indexing runs in the background - `DELETE /chat/knowledge/documents/{id}`: Delete a knowledge document - `POST /chat/knowledge/collections/{name}/sync`: Make a collection hold exactly these documents (up to 1,000): listed ones are written, the rest of the collection deleted - `GET /chat/knowledge/search`: Search the knowledge the assistant answers from, preferring a language ## Canned responses - `GET /chat/canned-responses`: List canned responses - `POST /chat/canned-responses`: Create a canned response - `PATCH /chat/canned-responses/{id}`: Update a canned response - `DELETE /chat/canned-responses/{id}`: Delete a canned response ## Routing rules - `GET /chat/routing-rules`: List distribution rules (evaluated top-down by position) - `POST /chat/routing-rules`: Create a distribution rule - `POST /chat/routing-rules/reorder`: Reorder distribution rules - `PATCH /chat/routing-rules/{id}`: Update a distribution rule - `DELETE /chat/routing-rules/{id}`: Delete a distribution rule ## Payments - `POST /payments`: Create a payment (Idempotency-Key supported) - `GET /payments`: List payments - `GET /payments/{id}`: Retrieve a payment - `GET /payments/{id}/ipn-deliveries`: IPN calls made for this payment (newest first) - `POST /payments/{id}/cancel`: Cancel a pending payment - `POST /payments/{id}/mark-paid`: Record money received outside the bank feed - `GET /payments/qr`: VietQR for any amount and description (webhook-only integrations; nothing is stored) - `POST /payments/simulate/transfer`: Simulate a bank transfer (development only) ## Transactions - `GET /payments/transactions`: List bank transactions - `GET /payments/transactions/{id}`: Retrieve a transaction - `POST /payments/statements`: Import a bank statement (CSV) to recover transactions the bank never pushed - `GET /payments/statements/{id}`: Statement import progress - `POST /payments/transactions/{id}/invoice-exempt`: Mark money in as needing no invoice (left out of invoice reconciliation) - `DELETE /payments/transactions/{id}/invoice-exempt`: Put the transaction back into invoice reconciliation ## Bank connections - `GET /payments/bank-connections`: List linked bank accounts - `POST /payments/bank-connections`: Start linking a bank account; the bank texts a code to the holder - `GET /payments/bank-connections/{id}`: Retrieve a bank connection - `PATCH /payments/bank-connections/{id}`: Change the label or matching settings - `POST /payments/bank-connections/{id}/confirm`: Confirm linking with the code - `POST /payments/bank-connections/{id}/unlink`: Start unlinking; returns a challenge when the bank confirms with a code - `POST /payments/bank-connections/{id}/unlink/confirm`: Confirm unlinking with the code - `POST /payments/bank-connections/{id}/acb/notification`: ACB: register the transaction-notification service (a code follows) - `PUT /payments/bank-connections/{id}/acb/notification`: ACB: change which transactions are notified (a code follows) - `DELETE /payments/bank-connections/{id}/acb/notification`: ACB: cancel the notification service (a code follows) - `GET /payments/bank-connections/{id}/acb/notification`: ACB: what the holder has registered - `POST /payments/bank-connections/{id}/acb/notification/confirm`: ACB: confirm a notification registration, change or cancellation with the code - `GET /payments/bank-connections/{id}/acb/virtual-accounts`: ACB: the virtual accounts under this account - `GET /payments/acb/virtual-accounts`: ACB: the virtual accounts an account already has, before it is linked — pick one in config.acb.virtual_account_number to adopt it instead of opening another - `GET /payments/acb/notifications`: ACB: every account registered for notifications under this partner ## E-invoices - `GET /einvoices/reconciliation`: Money in against invoices: what has one, what is missing one, what needs none, per day - `GET /einvoices/providers`: Providers you can connect, with the fields each needs - `POST /einvoices/connections/check`: Check provider credentials and list the account's templates (nothing is saved) - `GET /einvoices/connections`: List e-invoice provider connections - `POST /einvoices/connections`: Connect an e-invoice provider account (credentials and template are checked) - `GET /einvoices/connections/{id}`: Retrieve a provider connection - `DELETE /einvoices/connections/{id}`: Unlink a provider (its invoices are kept; the next provider becomes the default) - `PATCH /einvoices/connections/{id}`: Change default mode, template, credentials or make it the default - `POST /einvoices/connections/{id}/default`: Make this the default provider - `GET /einvoices/connections/{id}/templates`: Templates this provider account may issue on - `POST /einvoices/connections/{id}/sync`: Check every draft and unsettled invoice of this connection at the provider now - `POST /einvoices`: Create an invoice: the draft is created first, then (mode publish) sent for approval - `GET /einvoices`: List invoices - `GET /einvoices/{id}`: Retrieve an invoice - `PATCH /einvoices/{id}`: Edit a draft - `DELETE /einvoices/{id}`: Delete a draft - `POST /einvoices/{id}/publish`: Send a draft for approval (sign and issue it) - `POST /einvoices/{id}/retry`: Run the failed step again - `POST /einvoices/{id}/replace`: Issue a replacement invoice - `POST /einvoices/{id}/resolve`: Settle a conflict: keep your draft or the provider's - `POST /einvoices/{id}/sync`: Check this invoice at the provider now - `GET /einvoices/{id}/history`: Status history (who changed what, on which side) ## Checkout - `GET /pay/{id}`: What the hosted checkout page shows - `GET /pay/{id}/qr.svg`: VietQR image, with the hatang mark in the middle unless `logo=0` - `GET /pay/{id}/events`: Server-sent events with the payment status ## Integrations - `GET /integrations/{provider}/{action}`: Provider callback (verification) - `POST /integrations/{provider}/{action}`: Provider callback: MB Bank token (API 32) and balance change (API 90); e-invoice status changes ## Tracking - `POST /stores/apple/{appId}`: App Store Server Notifications v2 for one connected app (Apple-signed JWS in signedPayload) - `POST /stores/google/{appId}`: Google Play real-time developer notifications (Pub/Sub push) for one connected app - `GET /channels/{channel}/webhook`: Channel webhook verification (Zalo, Messenger) - `POST /channels/{channel}/webhook`: Channel webhook receiver - `GET /o/{token}`: Open-tracking pixel - `GET /c/{token}`: Click redirect - `GET /internal/tls-ask`: On-demand TLS check used by the reverse proxy - `POST /ses/events`: Amazon SNS endpoint for SES delivery, bounce and complaint events (SES mode only) - `POST /ses/inbound`: Amazon SNS endpoint for mail SES received for the chat inbox domain (stored in S3) - `GET /u/{token}`: Email preferences page (topics, unsubscribe from all); opening it changes nothing - `POST /u/{token}`: One-click unsubscribe (RFC 8058): from the topic in the link, else from everything - `POST /u/{token}/preferences`: Save the preferences page form --- # Chat gateway endpoints Public endpoints used by the chat widget and the native SDKs. Every /widget/v1 route checks the request Origin against the site's domain and allowed origins, unless the caller presents a site app key (x-hatang-app-key) or a visitor token minted through one; authenticated calls carry the visitor token from bootstrap. Realtime traffic uses the websocket at /ws?site=…(&app=…&install=… for apps), protocol v1; close codes: 4001 auth_timeout, 4003 auth_failed, 4004 protocol_outdated, 4005 session_replaced. See docs/chat-protocol.md and docs/chat-native-sdk.md. Base URL: https://chat.hatang.dev ## Widget - `POST /widget/v1/sites/{siteId}/bootstrap`: Create or resume a visitor session (browsers: allowed Origin; native SDKs: app key + `app.install_id`) - `POST /widget/v1/sites/{siteId}/conversations`: Start a conversation (HTTP fallback) - `GET /widget/v1/sites/{siteId}/conversations`: The visitor's own conversations, newest activity first, resolved ones included (an app's inbox) - `GET /widget/v1/sites/{siteId}/conversations/{id}/messages`: Timeline items of one of the visitor's conversations: after `after_seq` (sync) or before `before_seq` (earlier history); `hasMore` is kept one release for old bundles - `POST /widget/v1/sites/{siteId}/conversations/{id}/messages`: Send a message (HTTP fallback); content may be empty when attachments are given - `POST /widget/v1/sites/{siteId}/leads`: Capture a lead from any form: creates/identifies the visitor, opens a conversation, routes it and counts a conversion (no visitor token needed; 10/min per IP) - `POST /widget/v1/sites/{siteId}/offline-message`: Pre-chat form: name, email and first message while nobody is online - `POST /widget/v1/sites/{siteId}/uploads`: Upload an attachment (multipart, field `file`, 10 MB; images incl. HEIC, PDF, office files, zip, mp4, m4a) - `POST /widget/v1/sites/{siteId}/purchases`: Report a finished App Store / Google Play purchase from the app (SDK reportPurchase). With `signed_transaction` (StoreKit 2 JWS) or `purchase_token` (Play) it is checked with the store and recorded for this visitor (Play purchases are acknowledged); a store that says no is a 422. The team also gets an internal note in the open conversation - `PUT /widget/v1/sites/{siteId}/devices`: Native SDKs: register this phone's push token for the visitor (FCM v1 with the site's Firebase credentials); re-registering moves the token to the current visitor - `DELETE /widget/v1/sites/{siteId}/devices`: Forget this phone's push token - `POST /widget/v1/sites/{siteId}/reset`: Forget this visitor: the old token stops working and a fresh visitor is issued - `POST /widget/v1/sites/{siteId}/sessions/restore`: Exchange a magic-link token from a chat email for a visitor token (with an app key, the token is minted for that app) ## Files - `GET /files/{id}`: Download an attachment with a signed URL - `POST /agent/uploads`: Upload an attachment as an agent (Bearer agent token, multipart `file` + `site_id`; 10 MB, videos 25 MB, a .mov is stored as MP4 when the host has ffmpeg) ## Agent - `GET /app/version`: Support app update check (public): `update` is none, available (below APP__LATEST) or required (below APP__MIN). Apps send `X-Hatang-App: /` on /agent/* requests; a build below the minimum gets 426 upgrade_required - `PUT /agent/devices`: Register the support app on this phone or Mac (Bearer mobile agent token); upserts by device_id. Phones send `expo_push_token`; the Mac app (platform macos) sends `apns_token` and `apns_env` - `DELETE /agent/devices/{deviceId}`: Forget this phone (sign-out) - `GET /agent/devices/{deviceId}/prefs`: This phone's notification preferences - `PATCH /agent/devices/{deviceId}/prefs`: Change mute, event kinds, quiet hours or time zone for this phone - `GET /agent/canned`: Canned responses: the agent's own (private) ones first, then the team's - `POST /agent/canned`: Add a canned response: the agent's own, or the team's with `shared` (chat:manage). An empty shortcut is made from the title - `GET /agent/conversations/{id}/scheduled`: The conversation's scheduled messages (waiting or failed), soonest first - `POST /agent/conversations/{id}/scheduled`: Schedule a reply: sent at `send_at` as this agent's message. At least a minute ahead, within 30 days and before the channel's reply window closes (chat:reply) - `PATCH /agent/scheduled/{id}`: Change the text or the time of a scheduled (or failed) message - `DELETE /agent/scheduled/{id}`: Cancel a scheduled message - `POST /agent/scheduled/{id}/send`: Send a scheduled message now - `PATCH /agent/canned/{id}`: Edit one of the agent's own responses (the team's need chat:manage); fields are optional - `DELETE /agent/canned/{id}`: Delete one of the agent's own responses (the team's need chat:manage) - `GET /agent/profile-links`: The business's profile links: URL templates (https, {{user_id}} = verified identify() id else the claimed one marked unverified, {{email}}, {{phone}}, {{name}}, {{customer_id}}, {{conversation_id}}, {{site_id}}, {{field.}}, {{data.}}) the client fills per conversation; links with a missing value are hidden - `GET /agent/home`: Home summary for the signed-in employee: chat, orders, tickets, payments and employees sections by role and switched-on module, plus an attention list - `GET /agent/reminders`: The signed-in agent's open reminders (create and complete them over the socket) - `GET /agent/agents`: Employees who can pick up conversations (assign picker) - `GET /agent/conversations`: Inbox snapshot: `status` open (default; includes pending) | pending | resolved | closed | all, `scope` mine | unassigned | all, `q`, `after` cursor, `unanswered=1`, `flag`, `limit` ≤ 100 - `GET /agent/conversations/{id}`: One conversation with the latest 100 timeline items (messages carry signed attachment links) - `GET /agent/conversations/{id}/email`: Email side of a conversation: subject, CC participants, first-answer deadline and how each emailed reply landed (null when the conversation never used email) - `POST /agent/conversations/{id}/rating-request`: Ask the customer for a rating now (whether or not automatic CSAT is on); result "rated" when they already did - `POST /agent/conversations/{id}/block`: Block the conversation's customer: this and their later conversations go to Spam - `POST /agent/conversations/{id}/mute`: Mute (body { muted: true }) or unmute the conversation for the signed-in agent - `POST /agent/conversations/{id}/ai`: Pause (body { paused: true }) or resume the AI assistant in the conversation - `POST /agent/conversations/{id}/learn`: Teach the AI from one of our public replies: the AI reads the exchange, the closest knowledge and the AI instructions and drafts a Q/A and/or one instruction line (nothing is saved). Needs chat:reply; 429 ai_budget_exhausted, 503 ai_not_configured / ai_unavailable - `POST /agent/conversations/{id}/learn/save`: Save the confirmed draft: the Q/A joins the "Câu hỏi từ khách" FAQ (skipped when the same question is there; a matching open knowledge gap is answered), the instruction is appended as "- …" (409 instructions_full past 4000 characters). The reply is then re-sent as a message frame with metadata.learned - `POST /agent/messages/{id}/recall`: Recall a message we sent (web chat; personal Zalo on Zalo too); the customer keeps a recalled marker, the team a private note - `GET /agent/customers/{id}`: The customer card: record, latest notes and the business's custom fields (CRM on and readable) - `PATCH /agent/customers/{id}`: Edit any of name, email, phone, owner_id, stage, labels, fields - `POST /agent/customers/{id}/notes`: Add a note about the customer (body { body }) - `DELETE /agent/customers/{id}/notes/{noteId}`: Delete one of your own notes - `POST /agent/ai/drafts/{id}`: Insert or discard the AI assistant's suggested reply ## Hosted - `GET /c/{token}`: Hosted continuation page opened from a chat email; loads the widget full-page and restores the session - `GET /rate/{id}`: One-click CSAT from an email: records the score (s=1..5, signed link) and offers a comment form - `POST /rate/{id}`: Comment for an email rating (form post from the rating page) ## Assets - `GET /widget.js`: Widget loader (mutable, 5 min cache) - `GET /w/{file}`: Hashed widget bundle (immutable) - `GET /preview`: Live widget preview for the dashboard's site settings: the real bundle with a stub boot and no network, configured by postMessage from the dashboard (the only page allowed to frame it)