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-Keytells you which account and environment the call is for.Hatang-Signatureis 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
- Python
- PHP
- C#
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.
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
$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);
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:
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:
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.
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, readawait req.text(). - Using the other environment's secret key. Development and production have different keys.
Hatang-Environmentand thesk_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: what to do once the call is verified.
- Webhooks: the other signature scheme.
- Go live.