API reference
Webhooks
Signed notifications sent to your notify_url when a payment, refund or payout changes status.
HansaPay sends a webhook to your notify_url when a payment, refund or payout changes status.
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.
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"
}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"
}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.
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.
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')
})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
$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';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.
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.
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.
