# 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 <orders@shop.vn>",
    "to": "an@example.com",
    "subject": "Đơn hàng DH12",
    "html": "<p>Cảm ơn bạn!</p>",
    "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
