Getting started
Authentication
Sign every request with HMAC-SHA256, and verify every response and webhook the same way.
Signing requests
Every request carries four headers. HansaPay rejects the request if any of them is missing or the signature does not match.
| Header | Value |
|---|---|
X-MerchantID | Your merchant ID |
X-Timestamp | The current Unix time in seconds, for example 1790000000 |
X-Nonce | A new random string for every request, for example 16 hexadecimal characters |
X-Sign | The signature, computed as below |
Also send Content-Type: application/json.
How to compute X-Sign
- Serialize your JSON body once and keep the exact bytes. Send those same bytes; do not re-serialize the object after signing.
- Build the string to sign by joining four parts with a single line feed (
\n, byte0x0A) between them:There are no spaces around the line feeds; they are shown here only for readability.X-MerchantID \n X-Timestamp \n X-Nonce \n body - Compute HMAC-SHA256 of that string, using your secret key as the key. Use the secret key as text (its UTF-8 bytes). Do not decode it from hexadecimal, even though it looks like hexadecimal.
- Write the result as lower-case hexadecimal (64 characters). That is
X-Sign.
The method, the path and the query string are not part of the signature.
Worked example
With these inputs:
secret key 3f9a1c7e5b2d4a6f8e0c1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f1a2c4e6b8d0f13
X-MerchantID M_12345678
X-Timestamp 1790000000
X-Nonce a1b2c3d4e5f60718
body {"merchant_order_no":"ORDER-1001"}the string to sign is (each \n is one line-feed byte):
M_12345678\n1790000000\na1b2c3d4e5f60718\n{"merchant_order_no":"ORDER-1001"}and the signature is:
645214f7278db5fd81ec1a229e955e807459d68aab38edede33ec121b8f95ae8You can reproduce it on the command line:
printf 'M_12345678\n1790000000\na1b2c3d4e5f60718\n{"merchant_order_no":"ORDER-1001"}' \
| openssl dgst -sha256 -hmac '3f9a1c7e5b2d4a6f8e0c1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f1a2c4e6b8d0f13'Signing in code
import { createHmac, randomBytes } from 'node:crypto'
export async function callHansaPay(baseUrl, merchantId, secretKey, path, payload) {
const body = JSON.stringify(payload)
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = randomBytes(8).toString('hex')
const sign = createHmac('sha256', secretKey)
.update(`${merchantId}\n${timestamp}\n${nonce}\n${body}`)
.digest('hex')
const res = await fetch(baseUrl + '/api/v1' + path, {
method: 'POST',
body,
headers: {
'Content-Type': 'application/json',
'X-MerchantID': merchantId,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Sign': sign
}
})
return { headers: res.headers, rawBody: await res.text() }
}import hashlib, hmac, json, secrets, time
import requests
def call_hansapay(base_url, merchant_id, secret_key, path, payload):
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
timestamp = str(int(time.time()))
nonce = secrets.token_hex(8)
message = f"{merchant_id}\n{timestamp}\n{nonce}\n".encode("utf-8") + body
sign = hmac.new(secret_key.encode("utf-8"), message, hashlib.sha256).hexdigest()
res = requests.post(base_url + "/api/v1" + path, data=body, headers={
"Content-Type": "application/json",
"X-MerchantID": merchant_id,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Sign": sign,
})
return res.headers, res.contentVerifying responses
Every response to a correctly signed request is signed by HansaPay the same way, and carries the same four headers: X-MerchantID, X-Timestamp, X-Nonce and X-Sign. To verify a response:
- Check that
X-MerchantIDis your own merchant ID. - Read the raw response body bytes before parsing the JSON.
- Build
X-MerchantID \n X-Timestamp \n X-Nonce \n rawBodyfrom the response headers and body. - Compute HMAC-SHA256 with your secret key and compare it with
X-Signusing a constant-time comparison. HansaPay sendsX-Signas lower-case hexadecimal.
HansaPay's X-Timestamp is its current Unix time in seconds. For webhooks you may also reject a timestamp that is more than a few minutes away from your own clock.
Webhooks are verified in exactly the same way; see Verifying a webhook for code.
Authentication failures (HTTP 401 and 403) and an unreadable request body (HTTP 500) are not signed, because they are returned before HansaPay has identified your key.
Signature troubleshooting
If HansaPay answers HTTP 401 or 403, check these in order:
- Merchant ID.
X-MerchantIDmust be the merchant ID HansaPay gave you (M_followed by eight digits), not your login name. - Secret key. Use the secret key, not your portal password, and the key for the right environment. If a new key was issued, the old one no longer works.
- Key encoding. Use the key as text. Do not hex-decode it.
- String to sign. Exactly
X-MerchantID,X-Timestamp,X-Nonceand the body, in that order, separated by single line feeds (\n, not\r\n), with nothing before or after. - Body bytes. Sign the exact bytes you send. Serializing the object again after signing, pretty-printing, or letting your HTTP library re-encode the body all break the signature.
- Same values in headers and signature. The timestamp and nonce you sign must be the ones you send.
- Output. Hexadecimal, 64 characters.
Reproduce the worked example with your code. If you get the same signature, your algorithm is right, and the problem is in the inputs.
