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