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.

HeaderValue
X-MerchantIDYour merchant ID
X-TimestampThe current Unix time in seconds, for example 1790000000
X-NonceA new random string for every request, for example 16 hexadecimal characters
X-SignThe signature, computed as below

Also send Content-Type: application/json.

How to compute X-Sign

  1. Serialize your JSON body once and keep the exact bytes. Send those same bytes; do not re-serialize the object after signing.
  2. Build the string to sign by joining four parts with a single line feed (\n, byte 0x0A) between them:
    Text
    X-MerchantID \n X-Timestamp \n X-Nonce \n body
    There are no spaces around the line feeds; they are shown here only for readability.
  3. 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.
  4. 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:

Text
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):

Text
M_12345678\n1790000000\na1b2c3d4e5f60718\n{"merchant_order_no":"ORDER-1001"}

and the signature is:

Text
645214f7278db5fd81ec1a229e955e807459d68aab38edede33ec121b8f95ae8

You can reproduce it on the command line:

Shell
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() }
}

Verifying 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:

  1. Check that X-MerchantID is your own merchant ID.
  2. Read the raw response body bytes before parsing the JSON.
  3. Build X-MerchantID \n X-Timestamp \n X-Nonce \n rawBody from the response headers and body.
  4. Compute HMAC-SHA256 with your secret key and compare it with X-Sign using a constant-time comparison. HansaPay sends X-Sign as 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:

  1. Merchant ID. X-MerchantID must be the merchant ID HansaPay gave you (M_ followed by eight digits), not your login name.
  2. 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.
  3. Key encoding. Use the key as text. Do not hex-decode it.
  4. String to sign. Exactly X-MerchantID, X-Timestamp, X-Nonce and the body, in that order, separated by single line feeds (\n, not \r\n), with nothing before or after.
  5. 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.
  6. Same values in headers and signature. The timestamp and nonce you sign must be the ones you send.
  7. 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.