Skip to main content

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​

IPNWebhooksPolling
CoversOne payment (the ipn_url given when creating it)Everything in your business, both environmentsAnything, any time
Sent onEvery payment.* change except created, and every invoice.* change of that paymentThe event types the endpoint subscribes to–
Body{ id, event_id, type, environment, created_at, data: <payment> }{ type, created_at, data }The object
SignatureHatang-Signature: HMAC-SHA256 of the body with your secret keySvix: svix-id, svix-timestamp, svix-signatureYour API key
Retries1 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)–
Timeout10 s15 s–
Delivery logGET /payments/{id}/ipn-deliveriesGET /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, webhooks).
  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.
// 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, …}}
HeaderMeaning
Hatang-Access-KeyWhich account and environment the call is for (ak_…, or ak_test_… in development)
Hatang-SignatureLowercase hex HMAC-SHA256 of the raw body
Hatang-Signature-AlgorithmAlways HMAC-SHA256
Hatang-EventThe event type, the same as type in the body
Hatang-Delivery-IdThe same as id in the body; dedupe on it
Hatang-Environmentdevelopment or production
Body fieldMeaning
idThe delivery id (ipn_…), the same on every retry
event_idThe event that caused the call
typepayment.succeeded, payment.partially_paid, payment.overpaid, payment.expired, payment.canceled, or an invoice.* event of this payment
environmentdevelopment or production
created_atWhen the call was built
dataThe 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 https://api.hatang.dev/payments/pay_123 \
-H "Authorization: Bearer $HATANG_API_KEY"

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​

View this page as Markdown