Getting started
Responses & errors
The response envelope, field formats, error codes and keys, and how repeated requests are handled.
Response envelope
Every response has the same shape:
{
"code": 0,
"msg": "success",
"data": {}
}codeis0on success. Any other value is an error, anddatais left out.msgissuccess, or the error message with its key (see Errors).
The HTTP status is 200 for every signed response, including errors. Always read code, which is a field in the JSON body and not an HTTP status, even when its value looks like one (400, 422). The only responses with a different HTTP status are the errors HansaPay returns before it has identified your key: authentication failures (401, 403) and an unreadable request body (500). Those are not signed; see Authentication errors.
Formats
- Amounts are objects with a currency and a string value:
{"currency": "USD", "value": "12.50"}. Sendvalueas a string with at most two decimal places."12.5"and"12.50"are accepted;12.50(a JSON number) and"12.500"are refused. Responses always show two decimals. - Currencies are three-letter upper-case codes, such as
USDorEUR. - Times are RFC 3339 strings with an offset, for example
2026-09-26T18:31:02+08:00. Always read the offset; do not assume UTC. - Text is UTF-8.
Errors
Format
An error response has a non-zero code and a msg made of a message and a stable key, separated by |:
{"code":2002,"msg":"Order not found|orderNotFound"}- Handle errors by
codeand the key, the part after the last|. The keys are stable. The message text may be reworded. - Some messages include details, such as the name of a field.
- If you send
Accept-Language: en,msgcontains a plain English sentence without the key, for exampleOrder not found. Detail such as field names is then left out. Use this only for messages you display, not for handling errors.
Only known errors are passed on with their own message. Any other failure is reported as System busy, please try again later|systemBusy. It is usually safe to retry that, with a new order number if the original request may have created an order; query first.
Authentication errors
These are returned before HansaPay has identified your key. They use the HTTP status shown, are not signed, and always include the key. All other errors are signed and returned with HTTP 200; in the tables below, code is the value in the JSON body.
| HTTP | code | msg | Meaning |
|---|---|---|---|
| 401 | 401 | Missing authentication headers|unauthorized | One of the four signing headers is missing or empty |
| 401 | 401 | Merchant ID not found or invalid|merchantNotFound | The merchant ID is not recognised |
| 403 | 403 | Invalid signature|forbidden | The signature does not match. See Signature troubleshooting. |
| 500 | 500 | Internal server error|internalServerError | The request body could not be read |
{
"merchant_order_no": "ORDER-1001-069I"
}BODY='{"merchant_order_no":"ORDER-1001-069I"}'
TS=$(date +%s); NONCE=$(openssl rand -hex 8)
SIGN=$(printf '%s\n%s\n%s\n%s' "$MERCHANT_ID" "$TS" "$NONCE" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET_KEY" | sed 's/^.* //')
curl https://api-test.hansapay.io/api/v1/payments/query \
-H "Content-Type: application/json" \
-H "X-MerchantID: $MERCHANT_ID" -H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" -H "X-Sign: $SIGN" \
--data-raw "$BODY"// callHansaPay() is on the Authentication page, under "Signing in code".
const { rawBody } = await callHansaPay(BASE_URL, MERCHANT_ID, SECRET_KEY, "/payments/query", {
merchant_order_no: "ORDER-1001-069I"
})
const { code, msg, data } = JSON.parse(rawBody)# call_hansapay() is on the Authentication page, under "Signing in code".
headers, raw = call_hansapay(BASE_URL, MERCHANT_ID, SECRET_KEY, "/payments/query", {
"merchant_order_no": "ORDER-1001-069I",
})
res = json.loads(raw)POST /api/v1/payments/query HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420278
X-Nonce: ec212997fa0e67ed
X-Sign: c912240bd1c34a7900ac441190081f05c9f71d91b49d00186184af16223d5e91
{
"merchant_order_no": "ORDER-1001-069I"
}{
"code": 403,
"msg": "Invalid signature|forbidden"
}Request errors (all endpoints)
| code | Key | Example message | Meaning |
|---|---|---|---|
| 400 | invalidRequest | Invalid request: ... | A field is missing or has the wrong type or format. The text names the field by the server's internal name: TradeInfo.GoodsName is trade_info.goods_name. |
| 400 | invalidRequest | missing or invalid payment_method: ... | payment_method is missing |
| 3003 | unsupportedPaymentMethod | unsupported payment_method: VENMO | Unknown payment method |
| 400 | mutuallyRequiredFieldsMissing | merchant_order_no and order_no cannot both be empty | Send one of the two |
| 400 | mutuallyExclusiveFieldsConflict | merchant_order_no and order_no cannot both be provided | Send only one of the two |
{
"payment_method": "CARD",
"merchant_order_no": "ORDER-1010-069I",
"trans_amount": {
"currency": "USD",
"value": "10.00"
},
"notify_url": "https://merchant.example.com/hansapay/webhook",
"return_url": "https://shop.example.com/orders/complete",
"metadata": "customer-8841"
}BODY='{"payment_method":"CARD","merchant_order_no":"ORDER-1010-069I","trans_amount":{"currency":"USD","value":"10.00"},"notify_url":"https://merchant.example.com/hansapay/webhook","return_url":"https://shop.example.com/orders/complete","metadata":"customer-8841"}'
TS=$(date +%s); NONCE=$(openssl rand -hex 8)
SIGN=$(printf '%s\n%s\n%s\n%s' "$MERCHANT_ID" "$TS" "$NONCE" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET_KEY" | sed 's/^.* //')
curl https://api-test.hansapay.io/api/v1/payments/checkout \
-H "Content-Type: application/json" \
-H "X-MerchantID: $MERCHANT_ID" -H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" -H "X-Sign: $SIGN" \
--data-raw "$BODY"// callHansaPay() is on the Authentication page, under "Signing in code".
const { rawBody } = await callHansaPay(BASE_URL, MERCHANT_ID, SECRET_KEY, "/payments/checkout", {
payment_method: "CARD",
merchant_order_no: "ORDER-1010-069I",
trans_amount: {
currency: "USD",
value: "10.00"
},
notify_url: "https://merchant.example.com/hansapay/webhook",
return_url: "https://shop.example.com/orders/complete",
metadata: "customer-8841"
})
const { code, msg, data } = JSON.parse(rawBody)# call_hansapay() is on the Authentication page, under "Signing in code".
headers, raw = call_hansapay(BASE_URL, MERCHANT_ID, SECRET_KEY, "/payments/checkout", {
"payment_method": "CARD",
"merchant_order_no": "ORDER-1010-069I",
"trans_amount": {
"currency": "USD",
"value": "10.00",
},
"notify_url": "https://merchant.example.com/hansapay/webhook",
"return_url": "https://shop.example.com/orders/complete",
"metadata": "customer-8841",
})
res = json.loads(raw)POST /api/v1/payments/checkout HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420278
X-Nonce: d2bd880fed988cec
X-Sign: 34f6122a0b7092d549b89167746af05bab55b55ae17a253d4733c8ce1f934dd8
{
"payment_method": "CARD",
"merchant_order_no": "ORDER-1010-069I",
"trans_amount": {
"currency": "USD",
"value": "10.00"
},
"notify_url": "https://merchant.example.com/hansapay/webhook",
"return_url": "https://shop.example.com/orders/complete",
"metadata": "customer-8841"
}{
"code": 400,
"msg": "Invalid request: Key: 'CheckoutCardRequest.TradeInfo.GoodsName' Error:Field validation for 'GoodsName' failed on the 'required' tag|invalidRequest"
}Payment errors
| code | Key | Message | Meaning |
|---|---|---|---|
| 1001 | merchantFrozen | merchant account is disabled | Your account is suspended. See What to expect. |
| 2006 | amountOutOfRange | Amount is out of the allowed range | No available channel accepts this amount, or it cannot be rounded to a price point |
| 3003 | paymentMethodUnavailable | payment_method PAYPAL is not available | The method is not available, or not enabled for you |
| 422 | duplicateMerchantOrder | Merchant order number already exists; do not submit duplicates | See Idempotency |
| 422 | invalidAmount | Order amount must be a positive number | |
| 422 | validationFailed | Amount supports at most 2 decimal places | Also returned for "10.100" |
| 422 | notSupported | currency is not supported currently: EUR | Currency conversion is not available |
| 422 | notSupported | This payment method does not support this amount (it only accepts fixed amount tiers) | Apple Pay or Google Pay amount that cannot be rounded to a price point |
| 422 | paymentFailed | Payment request failed | The provider declined the payment. A FAILED webhook follows. |
| 422 | validationFailed | payment declined | Declined by HansaPay's risk checks |
| 422 | validationFailed | No payment channel available | |
| 422 | systemBusy | System busy, order creation failed | |
| 422 | systemBusy | System busy, please try again later | Any other failure, including an invalid card number, CVC or expiry date |
| 2002 | orderNotFound | Order not found | Query: no such order |
| 403 | orderAccessDenied | You are not allowed to query this order | Query: the order belongs to another merchant |
| 500 | queryFailed | Query failed | Query: try again |
Refund errors
All refund-creation errors use code 422; refund-query errors use code 2002.
| Key | Message |
|---|---|
invalidRefundAmount | Refund amount must be a positive number |
validationFailed | Refund amount supports at most 2 decimal places |
orderNotFound | Original order not found |
refundOrderNotPaid | The original order was not paid successfully and cannot be refunded |
validationFailed | The original order has been charged back and cannot be refunded |
refundAmountExceeded | Refund amount exceeds the original order amount |
duplicateRefundOrder | Refund order number already exists; do not submit duplicates |
validationFailed | The merchant balance does not cover this refund and its fee |
notSupported | This payment channel does not support refunds |
notSupported | This payment channel does not support partial refunds; only full refunds are allowed |
refundFailed | Refund request failed |
systemBusy | System busy, order creation failed |
systemBusy | System busy, please try again later |
refundNotFound | Refund order not found (query) |
Payout errors
| code | Key | Message |
|---|---|---|
| 5007 | unsupportedPayoutMethod | payment_method CASH_APP is not available for payouts (every valid request today) |
| 5007 | unsupportedPaymentMethod | unsupported payment_method: VENMO |
| 1001 | merchantFrozen | merchant account is disabled |
| 5008 | amountOutOfRange | payment_method CASH_APP only supports USD payouts, got EUR |
| 5008 | notSupported | currency BRL is not supported for payout currently |
| 5004 | insufficientFunds | insufficient merchant payout balance |
| 5002 | notFound | payout not found (query) |
| 422 | notFound | payout not found (cancel) |
| 5003 | payoutNotCancelable | payout not cancelable in current status: SUCCESS |
| 422 | notSupported | payout channel does not support cancel |
Idempotency
| Operation | Key | Scope | Repeat request |
|---|---|---|---|
| Create a payment | merchant_order_no | Your account, shared with your refund numbers | Refused: Merchant order number already exists; do not submit duplicates|duplicateMerchantOrder (code 422) |
| Create a refund | merchant_refund_no | Your refunds | Refused: Refund order number already exists; do not submit duplicates|duplicateRefundOrder (code 422) |
| Create a payout | merchant_payout_no | Your payouts | Refused |
| Webhook | event, order_no, status | See Duplicates and order |
Things to plan for:
- A repeated request is refused, not replayed. HansaPay does not return the original response for a repeated number. If a create request times out on your side, query by your number to find out what happened before trying again with a new number.
- A failed request still uses up the number. When a payment or refund is refused after HansaPay has recorded it (for example a declined card or a refused refund), the number cannot be used again.
- Use different numbers for payments and refunds. A refund number that matches one of your payment order numbers can block that payment number from being used later.
- Send each create request once. Do not send the same create request in parallel; wait for the answer, or query.
