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_amount reports the amount actually charged. requested_amount carries 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_no or merchant_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/create and /payments/checkout return code 1001 with the message merchant 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_methodAvailable
CARDYes, when a card channel is enabled for your account
APPLE_PAY, GOOGLE_PAYYes, when enabled for your account
CASH_APPYes, when enabled for your account
PAYPALNo
PIXNo

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 PENDING and becomes SUCCESS or FAILED later; there is no guaranteed time. You receive a refund.updated webhook 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_no and 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

EnvironmentBase URL
Testhttps://api-test.hansapay.io
Productionhttps://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 the X-MerchantID header.
  • 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:

JSON
{
  "code": 0,
  "msg": "success",
  "data": {}
}
  • code is 0 on success. Any other value is an error, and data is left out.
  • msg is success, 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"}. Send value as 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 USD or EUR.
  • 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.com names.
  • 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.

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.

3.1 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.

3.2 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'

3.3 Signing in code

Node.js:

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:

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.content

4. 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.

5. Payments

EndpointPurpose
POST /api/v1/payments/createCreate a payment. For cards, you send the card details and HansaPay charges the card directly.
POST /api/v1/payments/checkoutCreate a payment and get a link to HansaPay's hosted checkout page, where the payer enters their details.
POST /api/v1/payments/queryLook 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 to checkout_url. The payer can also choose Apple Pay or Google Pay on that page, if they are enabled for you.
  • Direct (/payments/create with CARD) 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.

FieldTypeRequiredDescription
payment_methodstringYesCARD, APPLE_PAY, GOOGLE_PAY, CASH_APP, PAYPAL or PIX. Upper case. See availability.
merchant_order_nostringYesYour order number, at most 64 characters. Must be unique among all your orders and refunds. See Idempotency.
trans_amountobjectYes{"currency": "USD", "value": "12.50"}. See Formats.
notify_urlstringYesWhere HansaPay sends webhooks for this order. At most 255 characters.
return_urlstringNoWhere the payer is sent after paying. At most 255 characters.
trade_infoobjectYesgoods_name (required, at most 128 characters) and description (optional, at most 255 characters).
metadatastringNoYour own data, returned unchanged in queries and webhooks. At most 255 characters.
payerobjectNoPayer details: email, mobile, user_name, user_agent, ext_type, payer_id, document. All optional.
client_ipstringNoThe payer's IP address.

5.2 Direct card payment

POST /api/v1/payments/create with "payment_method": "CARD". In addition to the common fields:

FieldTypeRequiredDescription
card.card_numberstringYes13 to 19 digits
card.cardholder_namestringYes
card.exp_monthintegerYes1 to 12
card.exp_yearintegerYesFour digits, for example 2029
card.cvcstringYes3 or 4 digits
billing_address.countrystringYesTwo-letter country code, for example US
billing_address.first_name, last_namestringNoAt most 64 characters each
billing_address.emailstringNoUsed for the payment receipt; if empty, payer.email is used
billing_address.phone, state, city, address, street_number, postal_code, documentstringNo
shipping_addressobjectNoIf sent, country, first_name, last_name, email, phone, state, city, address and postal_code are all required
device_ipstringYesThe payer's device IP address
browserobjectNoBrowser 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:

HTTP
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.

HTTP
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.

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

FieldDescription
payment_methodCARD
order_noHansaPay's order number
merchant_order_noYour order number
statusSee Payment statuses
trans_amountThe order amount, in the currency you sent. See Amounts and currency.
next_actionWhat to do next. See next_action.
masked_card_numberThe first six and last four digits of the card
three_ds_urlPresent when next_action is 3DS_VERIFICATION_REQUIRED
created_atWhen 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:

HTTP
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.

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

FieldDescription
order_no, merchant_order_no, payment_method, created_atAs for direct payments
statusCHECKOUT_REQUIRED for cards; PENDING for wallets
trans_amountThe order amount, in the currency you sent
requested_amountOnly when the amount was rounded to a price point
next_actionCHECKOUT_REQUIRED for cards, REDIRECT for wallets
checkout_urlSend the payer here
tokenThe 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_actionWhat to doField to use
CHECKOUT_REQUIREDRedirect the payer to the hosted checkout pagecheckout_url
REDIRECTRedirect the payer to the payment pagepay_url or checkout_url
3DS_VERIFICATION_REQUIREDRedirect the payer to their bank's 3-D Secure pagethree_ds_url
QRCODEShow the QR code to the payer. No available payment method uses this today.qr_code
SUCCESSNothing; the payment succeeded
PROCESSING, PENDINGNothing; wait for the webhook
FAILEDNothing; 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:

FieldDescription
order_noHansaPay's order number
merchant_order_noYour order number
HTTP
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

StatusMeaningFinal
PENDINGCreated; waiting for the payer or the providerNo
CHECKOUT_REQUIREDWaiting for the payer on the hosted checkout pageNo
3DS_VERIFICATION_REQUIREDWaiting for the payer to complete 3-D SecureNo
PROCESSINGSubmitted; waiting for the provider's resultNo
SUCCESSPaidYes, but see below
FAILEDNot paidUsually; see below
PARTIAL_REFUNDEDPaid, and part of it has been refundedNo
REFUNDEDPaid, and fully refundedYes
  • A payment that has been refunded changes to PARTIAL_REFUNDED or REFUNDED, but no payment.updated webhook is sent for that change. You receive refund.updated instead.
  • In rare cases a card payment reported as FAILED is later confirmed by the provider as paid, and changes to SUCCESS. You receive a second payment.updated webhook when that happens. Do not treat FAILED as 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:

Wheretrans_amount
/payments/create, /payments/checkout, /payments/query responsesThe currency and amount you sent
payment.updated and refund.updated webhooksThe USD amount charged
Your settlement records in the portalThe 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.updated webhook 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:

Text
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.99

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

Wheretrans_amountrequested_amount
/payments/create (card)The charged amountNot present
/payments/checkout, /payments/queryThe charged amount (USD orders)Your original amount, in USD
payment.updated webhookThe charged amount, in USDYour 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.

HTTP
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

EndpointPurpose
POST /api/v1/refunds/createRefund a payment
POST /api/v1/refunds/queryLook up a refund

7.1 Create a refund

FieldTypeRequiredDescription
original_order_nostringOne of these twoHansaPay's order number of the payment
original_merchant_order_nostringOne of these twoYour order number of the payment
merchant_refund_nostringYesYour refund number, at most 64 characters. See Idempotency.
refund_amountstringYesThe amount to refund, in the payment's original currency, for example "12.50"
reasonstringNoAt most 255 characters
notify_urlstringNoWhere to send refund.updated webhooks. If you leave it out, you receive no refund webhooks.
metadatastringNoReturned in the refund webhook
HTTP
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.

HTTP
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

StatusMeaning
PENDINGSubmitted and waiting for review or for the provider
SUCCESSThe money has been returned to the payer
FAILEDThe 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/precheck with an amount, returns code 5007 (for example payment_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.

EndpointPurpose
POST /api/v1/payouts/createSend money from your balance to a payee
POST /api/v1/payouts/queryLook up a payout
POST /api/v1/payouts/cancelCancel a payout that is still processing
POST /api/v1/payouts/precheckCheck your balance and the fee before creating a payout
HTTP
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

FieldTypeRequiredDescription
payment_methodstringYesCASH_APP, PAYPAL or PIX
merchant_payout_nostringYesYour payout number, at most 64 characters, unique among your payouts
trans_amountobjectYesThe amount the payee receives, not including the fee
payeeobjectYesaccount_id, account_type, full_name, email, mobile, country, document, payee_id
memostringNoAt most 128 characters
notify_urlstringYesWhere to send payout.updated webhooks
metadatastringNoReturned in queries and webhooks
client_ipstringNo

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.

HTTP
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

eventSent when
payment.updatedA payment becomes SUCCESS or FAILED
refund.updatedA refund is created (PENDING or SUCCESS), and when it becomes SUCCESS or FAILED
payout.updatedA 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.

HTTP
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

success
HTTP
POST /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

success

Body fields:

FieldDescription
eventpayment.updated, refund.updated or payout.updated
order_noHansaPay's number: the payment's order_no, the refund's refund_no, or the payout's payout_no
merchant_order_noYour number: merchant_order_no, merchant_refund_no or merchant_payout_no
payment_methodThe payment method
trans_amountPayments and refunds: the USD amount charged or refunded. Payouts: the payout amount. See Amounts and currency.
requested_amountPayments only, when the amount was rounded to a price point
trade_infoPayments: the trade_info you sent. Refunds: goods_name holds the refund reason.
statusThe status at the moment the webhook is sent
status_reasonPayouts only
metadataThe metadata you sent, or an empty string
original_order_no, original_merchant_order_noRefunds only: the payment that was refunded
finished_atPayments and payouts: when they completed
created_atWhen 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):

Node.js
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):

Python
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
<?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.updated for a payment that has since been refunded can show REFUNDED.

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

OperationKeyScopeRepeat request
Create a paymentmerchant_order_noYour account, shared with your refund numbersRefused: Merchant order number already exists; do not submit duplicates|duplicateMerchantOrder (code 422)
Create a refundmerchant_refund_noYour refundsRefused: Refund order number already exists; do not submit duplicates|duplicateRefundOrder (code 422)
Create a payoutmerchant_payout_noYour payoutsRefused
Webhookevent, order_no, statusSee 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 periodSettlement dayAvailable from
T+0Friday 4 SeptemberSaturday 5 September, 00:05
T+1Tuesday 8 SeptemberWednesday 9 September, 00:05
T+2Wednesday 9 SeptemberThursday 10 September, 00:05
T+3Thursday 10 SeptemberFriday 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 |:

JSON
{"code":2002,"msg":"Order not found|orderNotFound"}
  • Handle errors by code and 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, msg contains a plain English sentence without the key, for example Order 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.

HTTPcodemsgMeaning
401401Missing authentication headers|unauthorizedOne of the four signing headers is missing or empty
401401Merchant ID not found or invalid|merchantNotFoundThe merchant ID is not recognised
403403Invalid signature|forbiddenThe signature does not match. See Signature troubleshooting.
500500Internal server error|internalServerErrorThe request body could not be read
HTTP
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)

codeKeyExample messageMeaning
400invalidRequestInvalid 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.
400invalidRequestmissing or invalid payment_method: ...payment_method is missing
3003unsupportedPaymentMethodunsupported payment_method: VENMOUnknown payment method
400mutuallyRequiredFieldsMissingmerchant_order_no and order_no cannot both be emptySend one of the two
400mutuallyExclusiveFieldsConflictmerchant_order_no and order_no cannot both be providedSend only one of the two
HTTP
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

codeKeyMessageMeaning
1001merchantFrozenmerchant account is disabledYour account is suspended. See What to expect.
2006amountOutOfRangeAmount is out of the allowed rangeNo available channel accepts this amount, or it cannot be rounded to a price point
3003paymentMethodUnavailablepayment_method PAYPAL is not availableThe method is not available, or not enabled for you
422duplicateMerchantOrderMerchant order number already exists; do not submit duplicatesSee Idempotency
422invalidAmountOrder amount must be a positive number
422validationFailedAmount supports at most 2 decimal placesAlso returned for "10.100"
422notSupportedcurrency is not supported currently: EURCurrency conversion is not available
422notSupportedThis 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
422paymentFailedPayment request failedThe provider declined the payment. A FAILED webhook follows.
422validationFailedpayment declinedDeclined by HansaPay's risk checks
422validationFailedNo payment channel available
422systemBusySystem busy, order creation failed
422systemBusySystem busy, please try again laterAny other failure, including an invalid card number, CVC or expiry date
2002orderNotFoundOrder not foundQuery: no such order
403orderAccessDeniedYou are not allowed to query this orderQuery: the order belongs to another merchant
500queryFailedQuery failedQuery: try again

12.5 Refund errors

All refund-creation errors use code 422; refund-query errors use code 2002.

KeyMessage
invalidRefundAmountRefund amount must be a positive number
validationFailedRefund amount supports at most 2 decimal places
orderNotFoundOriginal order not found
refundOrderNotPaidThe original order was not paid successfully and cannot be refunded
validationFailedThe original order has been charged back and cannot be refunded
refundAmountExceededRefund amount exceeds the original order amount
duplicateRefundOrderRefund order number already exists; do not submit duplicates
validationFailedThe merchant balance does not cover this refund and its fee
notSupportedThis payment channel does not support refunds
notSupportedThis payment channel does not support partial refunds; only full refunds are allowed
refundFailedRefund request failed
systemBusySystem busy, order creation failed
systemBusySystem busy, please try again later
refundNotFoundRefund order not found (query)

12.6 Payout errors

codeKeyMessage
5007unsupportedPayoutMethodpayment_method CASH_APP is not available for payouts (every valid request today)
5007unsupportedPaymentMethodunsupported payment_method: VENMO
1001merchantFrozenmerchant account is disabled
5008amountOutOfRangepayment_method CASH_APP only supports USD payouts, got EUR
5008notSupportedcurrency BRL is not supported for payout currently
5004insufficientFundsinsufficient merchant payout balance
5002notFoundpayout not found (query)
422notFoundpayout not found (cancel)
5003payoutNotCancelablepayout not cancelable in current status: SUCCESS
422notSupportedpayout 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:

  1. Signing. Call /payments/query with {"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.
  2. Response verification. Verify the signature of that response as described in Verifying responses.
  3. A payment through to a webhook. Create a checkout payment, pay it, and check that your webhook handler verifies the signature and replies success.
  4. Duplicates. Send the same webhook body to your handler twice and check it is processed once.
  5. 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:

FieldValue
card_number4111111111111111
exp_month, exp_year12, 2030 (any future date)
cvc123
cardholder_nameAny 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.

FeatureWhere
Payment linksMerchant 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.
ChargebacksMerchant portal, Chargebacks. No webhook is sent.
Withdrawals and receiving accountsMerchant portal, Withdrawals
Balance and settlement recordsMerchant portal. /payouts/precheck also returns your balance.
Checkout appearanceMerchant 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:

  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.