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: <payment> } | { 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.xor169.254.169.254are 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
301or302is 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
- Verify the signature on the raw body, before parsing the JSON (IPN, webhooks).
- Dedupe. The IPN
id(andevent_id) and the webhooksvix-idare the same on every retry of the same call. - Trust the status, not the order of arrival. Calls can arrive out of order or twice. Read
data.status(or fetchGET /payments/{id}), and only ever move your order forward. - Check the amount and the environment. Compare
data.paid_amountwith your order, and checkenvironment: a development payment must never fulfil a real order.
// 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:
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, 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
- Node.js
- Python
- PHP
curl https://api.hatang.dev/payments/pay_123 \
-H "Authorization: Bearer $HATANG_API_KEY"
const { data: check } = await hatang.payments.verify("pay_123", { order_id: "1001", amount: 150_000, environment: "production" });
if (check?.verified) await fulfil("1001");
p = hatang.payments.get("pay_123")
if p["status"] in ("paid", "overpaid") and p["paid_amount"] >= 150000 and p["environment"] == "production":
fulfil("1001")
$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 in Node.js, PHP, Python and C#.
- Webhooks: set up an endpoint and the event list.
- Go live.