Skip to main content

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​

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.

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, 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​

View this page as Markdown