Developers
HansaPay Merchant API
- Production
- api.hansapay.io
- Test
- api-test.hansapay.io
- Requests
- POST /api/v1 · JSON
- Auth
- HMAC-SHA256 signature
Sections
This is the reference for merchants integrating with HansaPay from their own servers. It covers request signing, every endpoint, webhooks, errors and settlement timing. What you do in the merchant portal (withdrawals, receiving accounts, checkout appearance, payment links) is described in the Merchant Guide.
This document is published in English and Chinese (Chinese). The two versions have the same sections, anchors and examples. The examples were produced by calling a running HansaPay instance, not written by hand.
1. What to expect
Read this section first, whether you are integrating for the first time or moving an existing integration to HansaPay.
The request and response formats in this document may already be familiar to you, and an integration built for them will work without code changes. That is exactly why this section matters. When HansaPay behaves differently from what your code assumes, nothing fails loudly: there is no new field, no schema error, no 404. The difference shows up as a different amount, a different status or an error you have not seen before. Each item below is such a case.
1.1 Card amounts can be rounded down to a price point
Some card channels only accept a fixed list of prices. When your order goes to one of them, HansaPay rounds the amount down to the nearest price point and charges that.
- The rounding only happens when the drop is 5% or less. For example, 12.50 USD is charged as 11.99 USD, but 17.50 USD and 75.00 USD cannot be rounded (the nearest points are more than 5% lower).
- If no channel available to you can take the amount, the order is refused with
2006 Amount is out of the allowed range. trans_amountreports the amount actually charged.requested_amountcarries the amount you asked for, and is present only when rounding happened. See Price points for which responses carry it.- Webhooks report the charged amount. If your reconciliation matches a webhook to an order by amount, it will not match. Match on
order_noormerchant_order_no, never on amount.
Apple Pay and Google Pay have always worked this way on the same channels. What is new is that card payments can now be rounded as well.
1.2 Suspended merchant accounts are refused new payments
If HansaPay suspends your merchant account, new money movement is refused until the suspension is lifted:
/payments/createand/payments/checkoutreturn code1001with the messagemerchant account is disabled|merchantFrozen.- Payment links stop accepting payments, and portal actions that create or move money are disabled.
Everything else keeps working: authentication, /payments/query, refunds, webhooks for existing orders, and settlement of money already collected. The suspension check runs after the request itself has been validated, so a request that is also invalid in another way reports that problem first.
1.3 Some payment methods are accepted but not currently available
The API accepts these payment_method values: CARD, APPLE_PAY, GOOGLE_PAY, CASH_APP, PAYPAL and PIX. Not all of them can be processed today.
| payment_method | Available |
|---|---|
CARD | Yes, when a card channel is enabled for your account |
APPLE_PAY, GOOGLE_PAY | Yes, when enabled for your account |
CASH_APP | Yes, when enabled for your account |
PAYPAL | No |
PIX | No |
A request with an unavailable method passes validation and then fails with code 3003, for example payment_method PAYPAL is not available|paymentMethodUnavailable. The same error is returned for a method that exists but is not enabled for your account.
Card payments are charged in USD. Orders in another currency are converted first; see Amounts and currency.
1.4 Card refunds are full refunds and are reviewed before they complete
On card, Apple Pay and Google Pay payments:
- Only a full refund is possible, and only one refund per payment. A partial refund is refused with
This payment channel does not support partial refunds; only full refunds are allowed. - Every refund is reviewed before the money is returned. A refund starts as
PENDINGand becomesSUCCESSorFAILEDlater; there is no guaranteed time. You receive arefund.updatedwebhook when it changes. - A refund number that was refused cannot be reused. See Idempotency.
Cash App refunds complete immediately and can be partial.
1.5 Payouts are not currently offered
The /payouts/* endpoints are documented in full in Payouts, but no payout method is available today. Every valid payout request returns 5007, for example payment_method CASH_APP is not available for payouts|unsupportedPayoutMethod. Nothing is created and no balance is held.
To take money out of your HansaPay balance, request a withdrawal in the merchant portal. Withdrawals carry a fee and go to a receiving account that HansaPay has verified; see the Merchant Guide.
1.6 Chargebacks are not sent as webhooks
When a payer's bank reverses a payment (a chargeback), HansaPay records it and debits your balance, but no webhook is sent. The original payment's status does not change either. Chargebacks appear only in the merchant portal, under Chargebacks. Check that screen regularly, or ask your account manager about other ways to be told.
1.7 Responses and webhooks can report different amounts
For an order in a currency other than USD, API responses report the amount and currency you sent, while payment.updated and refund.updated webhooks report the USD amount that was charged. For USD orders the two are the same. See Amounts and currency for a worked example.
1.8 Error messages are English, with a stable key
Error messages are in English and end in a stable key, for example Order not found|orderNotFound. Only a known set of messages is passed on; any other failure is reported as System busy, please try again later|systemBusy. This includes some errors you can fix yourself, such as an invalid card number. See Errors.
1.9 Other behaviour to know
- Upstream reference numbers are not returned. Responses and webhooks carry HansaPay's
order_noand your own numbers only. - A payment for the wrong amount is not marked paid. If the provider reports a paid amount that differs from the order amount, the order does not become
SUCCESS, whether the difference is reported by webhook or found by a query. - Payment links have no API. They are created and managed in the merchant portal only. See Not available through the API.
2. Basics
2.1 Base URLs
| Environment | Base URL |
|---|---|
| Test | https://api-test.hansapay.io |
| Production | https://api.hansapay.io |
Every endpoint is under /api/v1 and uses POST with a JSON body. Test and production have separate credentials.
2.2 Credentials
HansaPay gives you two values:
- Merchant ID, for example
M_74209884. It is sent in theX-MerchantIDheader. - Secret key, a 64-character string. It signs your requests and HansaPay's responses and webhooks. You can see it in the merchant portal under Business profile, Developers, and replace it there with Reset API key. When the key is reset, the old one stops working immediately, so update your servers straight away.
Keep the secret key on your server. Never put it in a web page or a mobile app.
2.3 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.
2.4 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.
2.5 About the examples
Every request and response in this document was recorded from a running HansaPay instance, with test cards and a test merchant. Three things differ from what you will see on the wire:
- Response and webhook bodies are shown formatted for reading. HansaPay sends compact JSON, and signatures cover the bytes actually sent. Request bodies are shown exactly as they were sent and signed.
- Host names are replaced by
example.comnames. - The signatures were made with a test key that is not published, so you cannot reproduce them. Use the worked example to check your signing code.
3. 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.
3.1 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.TextX-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.
3.2 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'3.3 Signing in code
Node.js:
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() }
}Python:
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.content4. 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:
- 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.
5. Payments
| Endpoint | Purpose |
|---|---|
POST /api/v1/payments/create | Create a payment. For cards, you send the card details and HansaPay charges the card directly. |
POST /api/v1/payments/checkout | Create a payment and get a link to HansaPay's hosted checkout page, where the payer enters their details. |
POST /api/v1/payments/query | Look up a payment. |
Which of the first two to use:
- Checkout (
/payments/checkout) is the simplest. You never handle card data; you redirect the payer tocheckout_url. The payer can also choose Apple Pay or Google Pay on that page, if they are enabled for you. - Direct (
/payments/createwithCARD) requires you to collect card details on your own pages, which brings you into scope for card-data security rules (PCI DSS). - For wallets (
APPLE_PAY,GOOGLE_PAY,CASH_APP), both endpoints return a link to send the payer to.
5.1 Common request fields
These fields are used by both /payments/create and /payments/checkout, for every payment method.
| Field | Type | Required | Description |
|---|---|---|---|
payment_method | string | Yes | CARD, APPLE_PAY, GOOGLE_PAY, CASH_APP, PAYPAL or PIX. Upper case. See availability. |
merchant_order_no | string | Yes | Your order number, at most 64 characters. Must be unique among all your orders and refunds. See Idempotency. |
trans_amount | object | Yes | {"currency": "USD", "value": "12.50"}. See Formats. |
notify_url | string | Yes | Where HansaPay sends webhooks for this order. At most 255 characters. |
return_url | string | No | Where the payer is sent after paying. At most 255 characters. |
trade_info | object | Yes | goods_name (required, at most 128 characters) and description (optional, at most 255 characters). |
metadata | string | No | Your own data, returned unchanged in queries and webhooks. At most 255 characters. |
payer | object | No | Payer details: email, mobile, user_name, user_agent, ext_type, payer_id, document. All optional. |
client_ip | string | No | The payer's IP address. |
5.2 Direct card payment
POST /api/v1/payments/create with "payment_method": "CARD". In addition to the common fields:
| Field | Type | Required | Description |
|---|---|---|---|
card.card_number | string | Yes | 13 to 19 digits |
card.cardholder_name | string | Yes | |
card.exp_month | integer | Yes | 1 to 12 |
card.exp_year | integer | Yes | Four digits, for example 2029 |
card.cvc | string | Yes | 3 or 4 digits |
billing_address.country | string | Yes | Two-letter country code, for example US |
billing_address.first_name, last_name | string | No | At most 64 characters each |
billing_address.email | string | No | Used for the payment receipt; if empty, payer.email is used |
billing_address.phone, state, city, address, street_number, postal_code, document | string | No | |
shipping_address | object | No | If sent, country, first_name, last_name, email, phone, state, city, address and postal_code are all required |
device_ip | string | Yes | The payer's device IP address |
browser | object | No | Browser details for 3-D Secure: accept, user_agent, accept_language, java_enabled (boolean), color_depth, screen_height, screen_width, time_zone_offset, referer |
Request and response for a card that is charged straight away:
POST /api/v1/payments/create HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420275
X-Nonce: 8484f4a2742f05a2
X-Sign: 3477cc5a5ac3d6115c692674ec38080466b0e35896711f465e90e1c8c838488a
{
"payment_method": "CARD",
"merchant_order_no": "ORDER-1001-069I",
"trans_amount": {
"currency": "USD",
"value": "25.00"
},
"notify_url": "https://merchant.example.com/hansapay/webhook",
"return_url": "https://shop.example.com/orders/complete",
"trade_info": {
"goods_name": "Pro plan, 1 month",
"description": "Subscription renewal"
},
"metadata": "customer-8841",
"card": {
"card_number": "4242424242424242",
"cardholder_name": "Test Payer",
"exp_month": 12,
"exp_year": 2030,
"cvc": "123"
},
"billing_address": {
"country": "US",
"first_name": "Test",
"last_name": "Payer",
"email": "[email protected]",
"city": "San Francisco",
"address": "1 Market St",
"postal_code": "94105"
},
"device_ip": "203.0.113.10"
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420275
X-Nonce: 60b4428ae508f538
X-Sign: 42372a8a8351cc6ca9a9fbf3aaa21482b1b549d3008952f4049dcd74e1aabd01
{
"code": 0,
"msg": "success",
"data": {
"payment_method": "CARD",
"order_no": "362191050694987776",
"merchant_order_no": "ORDER-1001-069I",
"status": "SUCCESS",
"trans_amount": {
"currency": "USD",
"value": "25.00"
},
"next_action": "SUCCESS",
"masked_card_number": "424242******4242",
"created_at": "2026-09-26T18:57:55+08:00"
}
}When the card needs 3-D Secure, next_action is 3DS_VERIFICATION_REQUIRED. Send the payer to three_ds_url; the result arrives by webhook.
POST /api/v1/payments/create HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: 57d61c8cd7b86e64
X-Sign: af4704b259d3b63bd6cb7d400723d584712c2ffa800caead7e320b0e2dd1af0f
{
"payment_method": "CARD",
"merchant_order_no": "ORDER-1002-069I",
"trans_amount": {
"currency": "USD",
"value": "30.00"
},
"notify_url": "https://merchant.example.com/hansapay/webhook",
"return_url": "https://shop.example.com/orders/complete",
"trade_info": {
"goods_name": "Pro plan, 1 month",
"description": "Subscription renewal"
},
"metadata": "customer-8841",
"card": {
"card_number": "4000000000003220",
"cardholder_name": "Test Payer",
"exp_month": 12,
"exp_year": 2030,
"cvc": "123"
},
"billing_address": {
"country": "US",
"first_name": "Test",
"last_name": "Payer",
"email": "[email protected]",
"city": "San Francisco",
"address": "1 Market St",
"postal_code": "94105"
},
"device_ip": "203.0.113.10"
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: b21d3d875c3527f3
X-Sign: 51a026ae5aa6cee3557779cd4532745ba721ac52592b42be4bf494a294d249f7
{
"code": 0,
"msg": "success",
"data": {
"payment_method": "CARD",
"order_no": "362191055023509504",
"merchant_order_no": "ORDER-1002-069I",
"status": "3DS_VERIFICATION_REQUIRED",
"trans_amount": {
"currency": "USD",
"value": "30.00"
},
"next_action": "3DS_VERIFICATION_REQUIRED",
"masked_card_number": "400000******3220",
"three_ds_url": "https://provider.example.com/3ds/PI17904202767090018",
"created_at": "2026-09-26T18:57:56+08:00"
}
}When the card is declined, the request fails with Payment request failed|paymentFailed, and a payment.updated webhook with status FAILED follows for the same order. The error response does not include the order_no; the webhook does.
POST /api/v1/payments/create HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: abb8bc92feb8b39e
X-Sign: 2b56b10e0a89a9cef0cd6af78055a621aafd5ed52b337dd838e0728f116bcd2e
{
"payment_method": "CARD",
"merchant_order_no": "ORDER-1003-069I",
"trans_amount": {
"currency": "USD",
"value": "18.00"
},
"notify_url": "https://merchant.example.com/hansapay/webhook",
"return_url": "https://shop.example.com/orders/complete",
"trade_info": {
"goods_name": "Pro plan, 1 month",
"description": "Subscription renewal"
},
"metadata": "customer-8841",
"card": {
"card_number": "4000000000000002",
"cardholder_name": "Test Payer",
"exp_month": 12,
"exp_year": 2030,
"cvc": "123"
},
"billing_address": {
"country": "US",
"first_name": "Test",
"last_name": "Payer",
"email": "[email protected]",
"city": "San Francisco",
"address": "1 Market St",
"postal_code": "94105"
},
"device_ip": "203.0.113.10"
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: fab4c68e4d7513ce
X-Sign: 0cf959dbe61eafdc0b0a84f270e8a991006d103a2bd660be8fa8206a307ac2a0
{
"code": 422,
"msg": "Payment request failed|paymentFailed"
}Response fields:
| Field | Description |
|---|---|
payment_method | CARD |
order_no | HansaPay's order number |
merchant_order_no | Your order number |
status | See Payment statuses |
trans_amount | The order amount, in the currency you sent. See Amounts and currency. |
next_action | What to do next. See next_action. |
masked_card_number | The first six and last four digits of the card |
three_ds_url | Present when next_action is 3DS_VERIFICATION_REQUIRED |
created_at | When the order was created |
This response has no requested_amount, even when the amount was rounded to a price point. Use /payments/query or the webhook to see it.
5.3 Wallet payment
POST /api/v1/payments/create with payment_method set to APPLE_PAY, GOOGLE_PAY or CASH_APP takes only the common fields. The response gives a link to send the payer to:
POST /api/v1/payments/create HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: 0ec19c9c092902fc
X-Sign: c0cd8856d9898b01b6dfc6b406af98cb538f1e7c192bf4f4a8ff9c8cf195fb66
{
"payment_method": "GOOGLE_PAY",
"merchant_order_no": "ORDER-1004-069I",
"trans_amount": {
"currency": "USD",
"value": "19.99"
},
"notify_url": "https://merchant.example.com/hansapay/webhook",
"return_url": "https://shop.example.com/orders/complete",
"trade_info": {
"goods_name": "Pro plan, 1 month",
"description": "Subscription renewal"
},
"metadata": "customer-8841",
"payer": {
"email": "[email protected]"
}
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: 737ad7b1672e8301
X-Sign: 6bcbf080d02683409cdd4668509d646c705ea65a80988155c9b7b367c493a795
{
"code": 0,
"msg": "success",
"data": {
"order_no": "362191055195475968",
"merchant_order_no": "ORDER-1004-069I",
"payment_method": "GOOGLE_PAY",
"status": "PENDING",
"trans_amount": {
"currency": "USD",
"value": "19.99"
},
"next_action": "REDIRECT",
"pay_url": "https://provider.example.com/hosted/PI17904202767470020",
"created_at": "2026-09-26T18:57:56+08:00"
}
}Response fields: order_no, merchant_order_no, payment_method, status, trans_amount, requested_amount (only when rounded), next_action, pay_url (when next_action is REDIRECT), qr_code (when next_action is QRCODE) and created_at.
5.4 Hosted checkout
POST /api/v1/payments/checkout takes only the common fields. Card details, billing_address and device_ip are not accepted here; the payer enters them on the checkout page.
POST /api/v1/payments/checkout HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: 5ae7b2f2c0bb6677
X-Sign: d1416d769fc3b88d363ab86de82f45212e8e61921b1f8001f86c198e64b2fb9f
{
"payment_method": "CARD",
"merchant_order_no": "ORDER-1005-069I",
"trans_amount": {
"currency": "USD",
"value": "49.99"
},
"notify_url": "https://merchant.example.com/hansapay/webhook",
"return_url": "https://shop.example.com/orders/complete",
"trade_info": {
"goods_name": "Pro plan, 1 month",
"description": "Subscription renewal"
},
"metadata": "customer-8841",
"payer": {
"email": "[email protected]"
}
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: 80d502e8c56223a2
X-Sign: 538bb7d73d2e0ed82e660916121b40e8a5df75b985429b2a1b35f4e6c63f7f2a
{
"code": 0,
"msg": "success",
"data": {
"payment_method": "CARD",
"order_no": "362191055250001920",
"merchant_order_no": "ORDER-1005-069I",
"status": "CHECKOUT_REQUIRED",
"trans_amount": {
"currency": "USD",
"value": "49.99"
},
"next_action": "CHECKOUT_REQUIRED",
"token": "39cb37ce-03e2-47a6-80f6-3cf8ff463371",
"checkout_url": "https://pay.example.com/pay/39cb37ce-03e2-47a6-80f6-3cf8ff463371",
"created_at": "2026-09-26T18:57:56+08:00"
}
}Response fields:
| Field | Description |
|---|---|
order_no, merchant_order_no, payment_method, created_at | As for direct payments |
status | CHECKOUT_REQUIRED for cards; PENDING for wallets |
trans_amount | The order amount, in the currency you sent |
requested_amount | Only when the amount was rounded to a price point |
next_action | CHECKOUT_REQUIRED for cards, REDIRECT for wallets |
checkout_url | Send the payer here |
token | The last part of checkout_url. You do not need it. |
Save checkout_url as soon as you receive it. It cannot be retrieved again: /payments/query does not return it, and repeating the request with the same merchant_order_no is refused as a duplicate. The link expires after 30 minutes. An order whose link expired unpaid stays in CHECKOUT_REQUIRED; create a new order to try again.
On the checkout page, the payer may switch from card to Apple Pay or Google Pay. The order's payment_method then changes, and queries and webhooks report the wallet.
5.5 next_action
next_action tells your integration what to do after creating a payment. Dispatch on it, not on status.
| next_action | What to do | Field to use |
|---|---|---|
CHECKOUT_REQUIRED | Redirect the payer to the hosted checkout page | checkout_url |
REDIRECT | Redirect the payer to the payment page | pay_url or checkout_url |
3DS_VERIFICATION_REQUIRED | Redirect the payer to their bank's 3-D Secure page | three_ds_url |
QRCODE | Show the QR code to the payer. No available payment method uses this today. | qr_code |
SUCCESS | Nothing; the payment succeeded | |
PROCESSING, PENDING | Nothing; wait for the webhook | |
FAILED | Nothing; the payment failed |
Earlier integration material may describe a render_type field with the values qrcode, redirect, mobile and card. That field is not part of the merchant API; use next_action.
5.6 Query a payment
POST /api/v1/payments/query with exactly one of:
| Field | Description |
|---|---|
order_no | HansaPay's order number |
merchant_order_no | Your order number |
POST /api/v1/payments/query HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: 3537ecfe65a3d99a
X-Sign: 44520dfebb5607601422ce72135282a359057bce6924b81baa58eedc62484245
{
"merchant_order_no": "ORDER-1001-069I"
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: f9cfa8d620bbfe04
X-Sign: f5236c0ea10c8aacba6f0c69834951a6cba7f5fdd927b4cf42c95650b8e56903
{
"code": 0,
"msg": "success",
"data": {
"payment_method": "CARD",
"order_no": "362191050694987776",
"merchant_order_no": "ORDER-1001-069I",
"status": "SUCCESS",
"trans_amount": {
"currency": "USD",
"value": "25.00"
},
"paid_at": "2026-09-26T18:57:55+08:00",
"created_at": "2026-09-26T18:57:55+08:00",
"masked_card_number": "424242******4242",
"trade_info": {
"goods_name": "Pro plan, 1 month",
"description": "Subscription renewal"
},
"metadata": "customer-8841"
}
}Response fields: payment_method, order_no, merchant_order_no, status, trans_amount, requested_amount (only when rounded), paid_at (once paid), created_at, masked_card_number (cards), trade_info and metadata.
A query for a payment that is still in progress also asks the provider for the latest status, so querying is a reliable fallback when a webhook is late.
5.7 Payment statuses
| Status | Meaning | Final |
|---|---|---|
PENDING | Created; waiting for the payer or the provider | No |
CHECKOUT_REQUIRED | Waiting for the payer on the hosted checkout page | No |
3DS_VERIFICATION_REQUIRED | Waiting for the payer to complete 3-D Secure | No |
PROCESSING | Submitted; waiting for the provider's result | No |
SUCCESS | Paid | Yes, but see below |
FAILED | Not paid | Usually; see below |
PARTIAL_REFUNDED | Paid, and part of it has been refunded | No |
REFUNDED | Paid, and fully refunded | Yes |
- A payment that has been refunded changes to
PARTIAL_REFUNDEDorREFUNDED, but nopayment.updatedwebhook is sent for that change. You receiverefund.updatedinstead. - In rare cases a card payment reported as
FAILEDis later confirmed by the provider as paid, and changes toSUCCESS. You receive a secondpayment.updatedwebhook when that happens. Do not treatFAILEDas permanent in your fulfilment logic.
6. Amounts and currency
6.1 Orders in other currencies
HansaPay settles in USD. When you send an order in another currency, HansaPay converts it to USD at the current exchange rate plus a small margin, and the payer is charged the USD amount.
Which amount you see depends on where you look:
| Where | trans_amount |
|---|---|
/payments/create, /payments/checkout, /payments/query responses | The currency and amount you sent |
payment.updated and refund.updated webhooks | The USD amount charged |
| Your settlement records in the portal | The USD amount |
Example: you create an order for EUR 10.00. With an exchange rate of 1 EUR = 1.0870 USD and a margin of 0.5%, the payer is charged USD 10.92.
- The create response and every query report
{"currency": "EUR", "value": "10.00"}. - The
payment.updatedwebhook reports{"currency": "USD", "value": "10.92"}.
The exchange rate is not returned by the API. If you need the USD amount, take it from the webhook.
For orders in USD, all of these report the same amount.
6.2 Price points
On some card channels the charged amount is rounded down to a fixed price point (see What to expect). The card price points are, in USD:
9.99 10.99 11.99 12.99 13.99 14.99 17.99 19.99 24.99 29.99 30.99 39.99
49.99 59.99 99.99 124.99 129.99 149.99 199.99 249.99 299.99 399.99 499.99The amount is rounded down to the highest price point that is not above it. It is refused if it is below 9.99, above 499.99, or would drop by more than 5%. Examples: 10.00 becomes 9.99; 12.50 becomes 11.99; 17.50 is refused (14.99 would be 14% lower).
Apple Pay and Google Pay use a longer list, which includes every card price point, so a card checkout that the payer switches to Apple Pay is not rounded a second time.
When an amount is rounded:
| Where | trans_amount | requested_amount |
|---|---|---|
/payments/create (card) | The charged amount | Not present |
/payments/checkout, /payments/query | The charged amount (USD orders) | Your original amount, in USD |
payment.updated webhook | The charged amount, in USD | Your original amount, in USD |
For an order in another currency, rounding applies to the converted USD amount. trans_amount in the responses then still shows your original currency and amount, while the webhook shows the rounded USD amount.
POST /api/v1/payments/checkout HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: 1d3b7c3ca6869a67
X-Sign: 0299bea8a801160a058982bcfc78d216cf5da49c4416e7f6d2b3e4093374f1eb
{
"payment_method": "CARD",
"merchant_order_no": "ORDER-1007-069I",
"trans_amount": {
"currency": "USD",
"value": "12.50"
},
"notify_url": "https://merchant.example.com/hansapay/webhook",
"return_url": "https://shop.example.com/orders/complete",
"trade_info": {
"goods_name": "Pro plan, 1 month",
"description": "Subscription renewal"
},
"metadata": "customer-8841",
"payer": {
"email": "[email protected]"
}
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: 05e5f24d68c3bf03
X-Sign: 9282537bbdb9b4c83bca55b6d5aafed18882fb9fcbf395dbf9e23ed296052777
{
"code": 0,
"msg": "success",
"data": {
"payment_method": "CARD",
"order_no": "362191055719763968",
"merchant_order_no": "ORDER-1007-069I",
"status": "CHECKOUT_REQUIRED",
"trans_amount": {
"currency": "USD",
"value": "11.99"
},
"requested_amount": {
"currency": "USD",
"value": "12.50"
},
"next_action": "CHECKOUT_REQUIRED",
"token": "f4b89eab-7548-4dca-9104-83bd4672827d",
"checkout_url": "https://pay.example.com/pay/f4b89eab-7548-4dca-9104-83bd4672827d",
"created_at": "2026-09-26T18:57:56+08:00"
}
}7. Refunds
| Endpoint | Purpose |
|---|---|
POST /api/v1/refunds/create | Refund a payment |
POST /api/v1/refunds/query | Look up a refund |
7.1 Create a refund
| Field | Type | Required | Description |
|---|---|---|---|
original_order_no | string | One of these two | HansaPay's order number of the payment |
original_merchant_order_no | string | One of these two | Your order number of the payment |
merchant_refund_no | string | Yes | Your refund number, at most 64 characters. See Idempotency. |
refund_amount | string | Yes | The amount to refund, in the payment's original currency, for example "12.50" |
reason | string | No | At most 255 characters |
notify_url | string | No | Where to send refund.updated webhooks. If you leave it out, you receive no refund webhooks. |
metadata | string | No | Returned in the refund webhook |
POST /api/v1/refunds/create HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: 78990fe7fada4754
X-Sign: 1299634987c26fbc230219198c881ee157f0be82814f5af030afd41ebf173d3d
{
"original_merchant_order_no": "ORDER-1001-069I",
"merchant_refund_no": "REFUND-1001-069I",
"refund_amount": "25.00",
"reason": "Customer cancelled the subscription",
"notify_url": "https://merchant.example.com/hansapay/webhook",
"metadata": "ticket-5521"
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: 90e79faad1345e6f
X-Sign: d8d33e1fc73496fb989d251caa19550d01240dec9232f85c0ed8bd93f117c4cd
{
"code": 0,
"msg": "success",
"data": {
"refund_no": "362191056193720320",
"merchant_refund_no": "REFUND-1001-069I",
"original_order_no": "362191050694987776",
"original_merchant_order_no": "ORDER-1001-069I",
"status": "PENDING",
"refund_amount": {
"currency": "USD",
"value": "25.00"
},
"created_at": "2026-09-26T18:57:56+08:00"
}
}Response fields: refund_no, merchant_refund_no, original_order_no, original_merchant_order_no, status, refund_amount and created_at.
Rules:
- The payment must be paid (
SUCCESS, or already partly refunded) and must not have a chargeback. - The total of your refunds on a payment cannot exceed the payment amount.
- Your available balance must cover the refund plus the refund fee. The amount is held from your balance while the refund is pending, and released if it fails.
- On card, Apple Pay and Google Pay payments, only one full refund is possible (see What to expect).
A refund that is refused when you create it returns an error. The refund number is used up even so: HansaPay records the refused refund with status FAILED, and you can look it up by merchant_refund_no.
7.2 Query a refund
POST /api/v1/refunds/query with exactly one of refund_no or merchant_refund_no.
POST /api/v1/refunds/query HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420277
X-Nonce: 43dbcb1276ee2ad5
X-Sign: 68032f8d9bf975d67c3a23b037b35c204711b05eda666a923719c4f27c61a134
{
"merchant_refund_no": "REFUND-1001-069I"
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420277
X-Nonce: b414fe127391b7fd
X-Sign: b19079a7a5808c6dd53465e746ea330e1650c23e5fd283a757056423de83acea
{
"code": 0,
"msg": "success",
"data": {
"refund_no": "362191056193720320",
"merchant_refund_no": "REFUND-1001-069I",
"original_order_no": "362191050694987776",
"original_merchant_order_no": "ORDER-1001-069I",
"status": "SUCCESS",
"refund_amount": {
"currency": "USD",
"value": "25.00"
},
"reason": "Customer cancelled the subscription",
"created_at": "2026-09-26T18:57:56+08:00",
"updated_at": "2026-09-26T18:57:56+08:00"
}
}Response fields: refund_no, merchant_refund_no, original_order_no, original_merchant_order_no, status, refund_amount, reason, created_at and updated_at.
7.3 Refund statuses
| Status | Meaning |
|---|---|
PENDING | Submitted and waiting for review or for the provider |
SUCCESS | The money has been returned to the payer |
FAILED | The refund did not happen; any held amount has been released |
HansaPay checks pending refunds with the provider every few minutes, so you do not need to poll. A refund can become FAILED when:
- the review rejects it;
- the provider reports it as failed or closed;
- the provider has no record of it 30 minutes after it was submitted.
8. Payouts
Not currently offered. No payout method is available. Every valid request to
/payouts/create, and to/payouts/precheckwith an amount, returns code5007(for examplepayment_method CASH_APP is not available for payouts|unsupportedPayoutMethod). No payout is created and no balance is held. This section describes the endpoints as they are defined, so that it stays correct when payouts become available. To withdraw your balance today, use the merchant portal.
| Endpoint | Purpose |
|---|---|
POST /api/v1/payouts/create | Send money from your balance to a payee |
POST /api/v1/payouts/query | Look up a payout |
POST /api/v1/payouts/cancel | Cancel a payout that is still processing |
POST /api/v1/payouts/precheck | Check your balance and the fee before creating a payout |
POST /api/v1/payouts/create HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420278
X-Nonce: 1340ee63600096b1
X-Sign: 2fa5cf39f00c0adbbab399928c8ba85ecda7b111f1922c55a659d910cc0003b1
{
"payment_method": "CASH_APP",
"merchant_payout_no": "PAYOUT-1001-069I",
"trans_amount": {
"currency": "USD",
"value": "20.00"
},
"payee": {
"account_id": "$testpayee",
"account_type": "CASHTAG",
"full_name": "Test Payee"
},
"notify_url": "https://merchant.example.com/hansapay/webhook"
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420278
X-Nonce: 91ffab73168f7a78
X-Sign: 4dfbb6745aa4b2dba224fc19b604209d14e60ebef86e33c80502f84a89a47568
{
"code": 5007,
"msg": "payment_method CASH_APP is not available for payouts|unsupportedPayoutMethod"
}8.1 Create a payout
| Field | Type | Required | Description |
|---|---|---|---|
payment_method | string | Yes | CASH_APP, PAYPAL or PIX |
merchant_payout_no | string | Yes | Your payout number, at most 64 characters, unique among your payouts |
trans_amount | object | Yes | The amount the payee receives, not including the fee |
payee | object | Yes | account_id, account_type, full_name, email, mobile, country, document, payee_id |
memo | string | No | At most 128 characters |
notify_url | string | Yes | Where to send payout.updated webhooks |
metadata | string | No | Returned in queries and webhooks |
client_ip | string | No |
account_type is one of EMAIL, MOBILE, CASHTAG, USERID, BANK_ACCOUNT or USDT_ADDRESS; for PIX also CPF, CNPJ or PHONE.
Response fields: payment_method, payout_no, merchant_payout_no, status, trans_amount, fee, total_debit (amount plus fee, in USD), next_action (WAIT_NOTIFY, SUCCESS or FAILED) and created_at.
The fee is the payout amount times your payout fee rate, plus your per-payout fee. Your available balance must cover total_debit; otherwise the request fails with 5004 insufficient merchant payout balance|insufficientFunds.
8.2 Query a payout
POST /api/v1/payouts/query with exactly one of payout_no or merchant_payout_no. Response fields: payment_method, payout_no, merchant_payout_no, status, status_reason, trans_amount, fee, total_debit, payee (with the account masked), metadata, paid_at, created_at and updated_at. An unknown payout returns 5002 payout not found|notFound.
8.3 Cancel a payout
POST /api/v1/payouts/cancel with exactly one of payout_no or merchant_payout_no, and an optional reason (at most 128 characters). Only a payout in PROCESSING can be cancelled, and only if the channel supports it. Cancelling a payout that is already cancelled succeeds again. Response fields: payout_no, merchant_payout_no and status.
8.4 Precheck
POST /api/v1/payouts/precheck with payment_method and, optionally, trans_amount. Response fields: payment_method, currency, available_balance, unsettled_balance, frozen_balance, spendable_balance, estimated_fee and estimated_debit (when an amount was sent), and can_payout. Without an amount, can_payout only says whether your available balance is above zero; it does not mean a payout method is available.
POST /api/v1/payouts/precheck HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420278
X-Nonce: 9c65fecbd4d45fcc
X-Sign: 180b89709fede070012bc7e59500fcffa65365c48e10f59fae37151ad696eb74
{
"payment_method": "CASH_APP"
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420278
X-Nonce: 718fa2326eb9f0cc
X-Sign: 13db2a882bfa9ed81531594b33b2d6f8087f49dcf93e4de352d7b1e3c8895c36
{
"code": 0,
"msg": "success",
"data": {
"payment_method": "CASH_APP",
"currency": "USD",
"available_balance": {
"currency": "USD",
"value": "97.20"
},
"unsettled_balance": {
"currency": "USD",
"value": "137.00"
},
"frozen_balance": {
"currency": "USD",
"value": "80.00"
},
"spendable_balance": {
"currency": "USD",
"value": "97.20"
},
"can_payout": true
}
}8.5 Payout statuses
PENDING (accepted), PROCESSING (sent to the provider), SUCCESS, FAILED and CANCELED. The last three are final and are sent as payout.updated webhooks, with status_reason set to Payout failed, Payout request failed or Payout canceled where it applies.
9. Webhooks
HansaPay sends a webhook to your notify_url when a payment, refund or payout changes status.
9.1 Events
event | Sent when |
|---|---|
payment.updated | A payment becomes SUCCESS or FAILED |
refund.updated | A refund is created (PENDING or SUCCESS), and when it becomes SUCCESS or FAILED |
payout.updated | A payout becomes SUCCESS, FAILED or CANCELED |
No webhook is sent when a payment is refunded (you receive refund.updated instead), when a checkout link expires, or when a chargeback is recorded. Chargebacks are shown only in the merchant portal.
9.2 The request
HansaPay sends POST with Content-Type: application/json and the same four headers as a signed response: X-MerchantID, X-Timestamp, X-Nonce and X-Sign.
POST /hansapay/webhook HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420276
X-Nonce: 1543c8c4bf866143
X-Sign: e238b18cfe30ba810dd85630e4a356189c65af1979d450e47ef28db6e26007fa
{
"event": "payment.updated",
"order_no": "362191050694987776",
"merchant_order_no": "ORDER-1001-069I",
"payment_method": "CARD",
"trans_amount": {
"currency": "USD",
"value": "25.00"
},
"trade_info": {
"goods_name": "Pro plan, 1 month",
"description": "Subscription renewal"
},
"status": "SUCCESS",
"metadata": "customer-8841",
"finished_at": "2026-09-26T18:57:55+08:00",
"created_at": "2026-09-26T18:57:55+08:00"
}
HTTP/1.1 200 OK
Content-Type: text/plain
successPOST /hansapay/webhook HTTP/1.1
Content-Type: application/json
X-MerchantID: M_74209884
X-Timestamp: 1790420277
X-Nonce: a2226599edc2bd0d
X-Sign: ceaf622d474fba1bfff5acde621017abfeb06ad417b9539c2323204ed3c0e5d2
{
"event": "refund.updated",
"order_no": "362191056193720320",
"merchant_order_no": "REFUND-1001-069I",
"payment_method": "CARD",
"trans_amount": {
"currency": "USD",
"value": "25.00"
},
"trade_info": {
"goods_name": "Customer cancelled the subscription"
},
"status": "SUCCESS",
"metadata": "ticket-5521",
"original_order_no": "362191050694987776",
"original_merchant_order_no": "ORDER-1001-069I",
"created_at": "2026-09-26T18:57:56+08:00"
}
HTTP/1.1 200 OK
Content-Type: text/plain
successBody fields:
| Field | Description |
|---|---|
event | payment.updated, refund.updated or payout.updated |
order_no | HansaPay's number: the payment's order_no, the refund's refund_no, or the payout's payout_no |
merchant_order_no | Your number: merchant_order_no, merchant_refund_no or merchant_payout_no |
payment_method | The payment method |
trans_amount | Payments and refunds: the USD amount charged or refunded. Payouts: the payout amount. See Amounts and currency. |
requested_amount | Payments only, when the amount was rounded to a price point |
trade_info | Payments: the trade_info you sent. Refunds: goods_name holds the refund reason. |
status | The status at the moment the webhook is sent |
status_reason | Payouts only |
metadata | The metadata you sent, or an empty string |
original_order_no, original_merchant_order_no | Refunds only: the payment that was refunded |
finished_at | Payments and payouts: when they completed |
created_at | When the order was created |
A failed payment webhook does not say why the payment failed.
9.3 Verifying a webhook
Verify the signature before acting on a webhook. Read the raw body first; a signature computed over a re-serialized object will not match.
Node.js (Express):
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'
const app = express()
app.post('/hansapay/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const merchantId = req.get('X-MerchantID')
const message = `${merchantId}\n${req.get('X-Timestamp')}\n${req.get('X-Nonce')}\n`
const expected = createHmac('sha256', process.env.HANSAPAY_SECRET_KEY)
.update(Buffer.concat([Buffer.from(message), req.body]))
.digest()
const received = Buffer.from(req.get('X-Sign') || '', 'hex')
if (merchantId !== process.env.HANSAPAY_MERCHANT_ID ||
received.length !== expected.length || !timingSafeEqual(received, expected)) {
return res.status(401).send('invalid signature')
}
const event = JSON.parse(req.body.toString('utf8'))
// Record the event idempotently, then acknowledge.
res.type('text/plain').send('success')
})Python (Flask):
import hashlib, hmac, os
from flask import Flask, request
app = Flask(__name__)
@app.post("/hansapay/webhook")
def hansapay_webhook():
raw = request.get_data()
merchant_id = request.headers.get("X-MerchantID", "")
message = f"{merchant_id}\n{request.headers.get('X-Timestamp', '')}\n{request.headers.get('X-Nonce', '')}\n".encode("utf-8") + raw
expected = hmac.new(os.environ["HANSAPAY_SECRET_KEY"].encode("utf-8"), message, hashlib.sha256).hexdigest()
if merchant_id != os.environ["HANSAPAY_MERCHANT_ID"] or not hmac.compare_digest(expected, request.headers.get("X-Sign", "").lower()):
return "invalid signature", 401
event = request.get_json()
# Record the event idempotently, then acknowledge.
return "success", 200, {"Content-Type": "text/plain"}PHP:
<?php
$raw = file_get_contents('php://input');
$merchantId = $_SERVER['HTTP_X_MERCHANTID'] ?? '';
$message = $merchantId . "\n" . ($_SERVER['HTTP_X_TIMESTAMP'] ?? '') . "\n" . ($_SERVER['HTTP_X_NONCE'] ?? '') . "\n" . $raw;
$expected = hash_hmac('sha256', $message, getenv('HANSAPAY_SECRET_KEY'));
if ($merchantId !== getenv('HANSAPAY_MERCHANT_ID') || !hash_equals($expected, strtolower($_SERVER['HTTP_X_SIGN'] ?? ''))) {
http_response_code(401);
exit('invalid signature');
}
$event = json_decode($raw, true);
// Record the event idempotently, then acknowledge.
header('Content-Type: text/plain');
echo 'success';9.4 Acknowledging a webhook
Reply with a body of exactly success: seven lower-case letters, with no quotes, no JSON and no trailing newline. HansaPay looks only at the body. Any other body counts as a failure, even with HTTP 200, and the webhook is sent again.
Reply within 5 seconds. Do slow work after replying.
9.5 Retries
A webhook that is not acknowledged is sent again, up to 9 attempts in total. The waits between attempts grow from about 15 seconds to about 40 minutes; the last attempt is about 80 to 100 minutes after the first. After that, HansaPay stops trying. Use /payments/query or /refunds/query to catch anything you missed.
Every attempt is signed again, with a new timestamp and nonce.
9.6 Duplicates and order
- The same webhook can arrive more than once, for example after a retry, or because HansaPay support resent it.
- Webhooks can arrive out of order.
- The body shows the status at the time of sending, not at the time of the change. A retried
payment.updatedfor a payment that has since been refunded can showREFUNDED.
Webhooks carry no event ID. Treat each one as "this order changed; here is its current state". Deduplicate on event, order_no and status together, and if in doubt, query the order.
10. 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.
11. Settlement timing
Money you collect becomes available for withdrawal after a settlement period agreed with HansaPay, written T+N (shown in the portal as, for example, "Next business day (T+1)").
- N counts business days, not calendar days. A business day is Monday to Friday, except these six US holidays: New Year's Day, Memorial Day, Independence Day, Labor Day, Thanksgiving Day and Christmas Day. When New Year's Day, Independence Day or Christmas Day falls on a Saturday, the Friday before is the holiday; on a Sunday, the Monday after.
- Other US public holidays, such as Martin Luther King Jr. Day, Juneteenth and the day after Thanksgiving, are business days.
- Day T is the day the order was created, not the day it was paid. The day itself is not counted.
- The funds become available shortly after midnight at the end of the settlement day (00:05).
- There is no cut-off time within a day. Dates are counted in HansaPay's operating time zone.
- With
T+0, money settles on the day the order was created, even at a weekend.
Worked example: an order created on Friday 4 September 2026. Monday 7 September is Labor Day.
| Settlement period | Settlement day | Available from |
|---|---|---|
| T+0 | Friday 4 September | Saturday 5 September, 00:05 |
| T+1 | Tuesday 8 September | Wednesday 9 September, 00:05 |
| T+2 | Wednesday 9 September | Thursday 10 September, 00:05 |
| T+3 | Thursday 10 September | Friday 11 September, 00:05 |
Refunds are settled on the same calendar, counted from when the refund is confirmed.
12. Errors
12.1 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.
12.2 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 |
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"
}
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
{
"code": 403,
"msg": "Invalid signature|forbidden"
}12.3 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 |
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"
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-MerchantID: M_74209884
X-Timestamp: 1790420278
X-Nonce: ed238586c4dadaac
X-Sign: 3d5f1125ab51c59c61eec1797b508da13ac214a320692bd99c2ada7434004572
{
"code": 400,
"msg": "Invalid request: Key: 'CheckoutCardRequest.TradeInfo.GoodsName' Error:Field validation for 'GoodsName' failed on the 'required' tag|invalidRequest"
}12.4 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 |
12.5 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) |
12.6 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 |
13. Testing
Integrate against the test environment (https://api-test.hansapay.io) first. It uses separate credentials.
What to test before going live:
- Signing. Call
/payments/querywith{"merchant_order_no":"TEST-DOES-NOT-EXIST"}. A correctly signed request returns{"code":2002,"msg":"Order not found|orderNotFound"}; a signing mistake returns HTTP 401 or 403. - Response verification. Verify the signature of that response as described in Verifying responses.
- A payment through to a webhook. Create a checkout payment, pay it, and check that your webhook handler verifies the signature and replies
success. - Duplicates. Send the same webhook body to your handler twice and check it is processed once.
- Failure paths. A declined payment, a refund, and an order in a currency other than USD, so that you see both amounts described in Amounts and currency.
In the test environment, card payments go to the card provider's sandbox. Use this test card:
| Field | Value |
|---|---|
card_number | 4111111111111111 |
exp_month, exp_year | 12, 2030 (any future date) |
cvc | 123 |
cardholder_name | Any name |
The sandbox does not decide the outcome from the card number. A sandbox payment waits on the provider's test checkout page, which offers "Mark as Paid" to complete it; you then receive the payment.updated webhook as in production. Ask your account manager for any other test outcomes you need, such as a declined payment.
14. Not available through the API
These features exist in the merchant portal only. There is no API endpoint for them.
| Feature | Where |
|---|---|
| Payment links | Merchant portal, Payment links. Links are created there and paid at a public HansaPay page. Payments made through a link appear as ordinary orders and send the usual payment.updated webhooks. |
| Chargebacks | Merchant portal, Chargebacks. No webhook is sent. |
| Withdrawals and receiving accounts | Merchant portal, Withdrawals |
| Balance and settlement records | Merchant portal. /payouts/precheck also returns your balance. |
| Checkout appearance | Merchant portal, Checkout appearance |
See the Merchant Guide for each of these.
15. 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.
