# 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
<?php
$secret = getenv('HATANG_SECRET_KEY'); // sk_…
$raw = file_get_contents('php://input');
$signature = strtolower(trim($_SERVER['HTTP_HATANG_SIGNATURE'] ?? ''));
$expected = hash_hmac('sha256', $raw, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}
$ipn = json_decode($raw, true);
// dedupe on $ipn['id'], then update the order from $ipn['data']['status']
http_response_code(200);
```

**C#**

```csharp
using System.Security.Cryptography;
using System.Text;

app.MapPost("/hatang/ipn", async (HttpRequest req) =>
{
    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
