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

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.

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.

Webhook from HansaPay
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"
}
200 · your reply
success
Webhook from HansaPay
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"
}
200 · your reply
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.

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')
})

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