Guide to HitPay Webhooks: Features and Best Practices
HitPay webhooks tell your systems when money moves: a payment completes or is refunded, an order is created or changes status, an invoice is issued, or a transfer to a bank account is scheduled, paid or fails. If you take payments with HitPay, online or at the point of sale in Singapore and Southeast Asia, event webhooks are how those changes reach your order system, accounting or notifications.
This guide covers how HitPay event webhooks are registered and signed, how to tell one event from another, how they differ from HitPay's older callbacks, and the practices that keep a HitPay receiver reliable.
What are HitPay webhooks?
HitPay has two ways of notifying you, and they are signed differently:
- Event webhooks, which this guide covers. You register an endpoint under Developers > Webhooks, and HitPay sends a JSON
POSTfor each event. Each request has aHitpay-Signatureheader: the hex HMAC-SHA256 of the raw body, keyed with that endpoint's own salt. - Legacy callbacks for payment requests, recurring payments, orders and plugins such as Shopify, Wix and Xero. These put an
hmacfield in the payload, computed over the sorted and concatenated parameters with the API-key salt from the main Developers page.
Mixing them up is the most common verification mistake. The salt on the Developers page next to your API key is not the salt for event webhooks.
HitPay webhook features
| Feature | Details |
|---|---|
| Configuration | Dashboard, Developers > Webhooks, or POST /v1/webhook-events |
| URL rules | HTTPS, resolving to a public address. Loopback and private addresses are rejected |
| Event headers | Hitpay-Event-Object and Hitpay-Event-Type |
| Signature header | Hitpay-Signature |
| Scheme | HMAC-SHA256, bare lowercase hex |
| Signed content | The raw JSON body, nothing else |
| Secret | A salt per webhook endpoint, on its detail view |
| Body | The object itself, with no envelope or event name |
| User agent | HitPay v2.0 |
| Retries, timeout, source IPs | Not documented |
Common events
| Area | Events | Fires when |
|---|---|---|
| Charges | charge.created, charge.updated, charge.failed | A payment completes, is refunded or partially refunded, or fails |
| Orders | order.created, order.updated | An order is created, or its status changes |
| Invoices | invoice.created, invoice.updated | An invoice is created or updated |
| Payouts | payout.created | A payout completes |
| Transfers | transfer.created, transfer.updated, transfer.scheduled, transfer.processing, transfer.paid, transfer.failed, transfer.canceled | A transfer moves through its lifecycle |
| Payment requests | payment_, payment_request.failed | A payment request is paid, or receives an error |
| Recurring billing | recurring_, recurring_, recurring_ | A payment method is attached to or detached from a subscription, or the subscription changes |
The body is the object itself, so there's no event name to read from it. HitPay tells you to use the headers instead: Hitpay-Event-Object is charge, payout, invoice, order or transfer, and Hitpay-Event-Type is created or updated. Joined, they give names such as charge.created.
That covers the created and updated events. HitPay doesn't document how the other events, such as transfer.paid or payment_, appear in the headers. For those, look at the object's status too. Here is HitPay's payout.created example:
{
"id": "9e9be893-9fee-4916-aca0-403e8e42b99e",
"bank_name": "MALAYAN BANKING BERHAD",
"bank_swift_code": "MBBESGS2XXX",
"bank_account_number": "000123454",
"currency": "sgd",
"amount": 100,
"status": "succeeded",
"created_at": "2025-04-06T15:29:51+08:00"
}
Charge bodies are much larger. A charge.created includes the charge's status, amount, currency, refunded_amount, the customer, the full order with its line items, payment_provider details and the business_id. Amounts are decimals in the currency's major unit, such as 913.84, and currency codes are lowercase.
transfer.failed carries a numeric failure code. HitPay publishes the list, from 100101 (invalid account number) through 100501 (insufficient balance) to 101202 (duplicate payment).
Platform accounts
If you use HitPay's Platform APIs, the platform has to register its own webhook endpoints first. Charge events for sub-account payments (charge.created, charge.failed, charge.updated) go to the platform's endpoints, and you identify the sub-account from business_id. Payment request events currently go to the sub-account's endpoints.
Setting up HitPay webhooks
- In the HitPay dashboard, go to Developers > Webhooks and add a webhook with a name and your HTTPS URL. You can also use
POST /v1/webhook-events. - Open the webhook's detail view and copy its salt. Store it server-side, for example as
HITPAY_WEBHOOK_SALT. - Make a test payment and check that your endpoint receives and verifies the event.
If you receive HitPay webhooks through Hookdeck, create a source with the HitPay type and paste that webhook's salt. Only these event webhooks, which send a Hitpay-Signature header, are supported. Legacy payment-request callbacks, which carry an hmac field instead, aren't.
Securing HitPay webhooks
Compute an HMAC-SHA256 of the raw request body with the webhook's salt as the key, hex-encode it, and compare it with Hitpay-Signature in constant time.
const crypto = require('crypto');
const express = require('express');
function verifyHitPaySignature(rawBody, signatureHeader, salt) {
if (!signatureHeader || !salt) return false;
const expected = crypto.createHmac('sha256', salt).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/hitpay', express.raw({ type: '*/*' }), (req, res) => {
const salt = process.env.HITPAY_WEBHOOK_SALT; // the webhook's salt, not the API-key salt
if (!salt) return res.sendStatus(500); // fail closed
if (!verifyHitPaySignature(req.body, req.get('hitpay-signature'), salt)) {
return res.sendStatus(401);
}
const eventName = `${req.get('hitpay-event-object')}.${req.get('hitpay-event-type')}`;
const object = JSON.parse(req.body.toString('utf8'));
// Route on eventName and object.status; deduplicate, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_hitpay_signature(raw_body: bytes, signature_header, salt: str) -> bool:
if not signature_header or not salt:
return False
expected = hmac.new(salt.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(
signature_header.strip().lower().encode("utf-8"), expected.encode("utf-8")
)
# In FastAPI: raw_body = await request.body()
# verify_hitpay_signature(raw_body, request.headers.get("hitpay-signature"),
# os.environ["HITPAY_WEBHOOK_SALT"])
HitPay's Node sample mounts express.raw({ type: 'application/json' }) and calls crypto.timingSafeEqual without a length check, which throws if the header is missing or the wrong length. The length check above returns false instead.
There is no timestamp in the request, so nothing tells you how old it is.
Make HitPay webhooks production-ready. Hookdeck Event Gateway verifies the
Hitpay-Signatureheader with your webhook's salt, stores every payment, order and transfer event, and delivers it to your service with retries you control.
HitPay webhook limitations and pain points
Two salts, two schemes
The Problem: Every event webhook fails verification, even though you're using "the salt".
Why It Happens: HitPay shows an API-key salt on the main Developers page and a separate salt for each webhook endpoint. Event webhooks use the per-webhook salt. The API-key salt signs legacy callbacks, with a different construction, in an hmac body field.
Workarounds:
- Copy the salt from the webhook's detail view under Developers > Webhooks.
- If a request has an
hmacfield and noHitpay-Signatureheader, it's a legacy callback. Verify it with HitPay's legacy method, separately.
How Hookdeck Can Help: Hookdeck's HitPay source verifies Hitpay-Signature with the webhook salt you paste. Hookdeck answers HitPay with a 200 either way, then records a request that fails the check as failed verification, so it never reaches your destination, and the stored headers show at once whether it was a legacy callback.
No event name in the body
The Problem: You can't tell transfer.paid from transfer.failed, or a charge from an order, by reading the JSON.
Why It Happens: The body is the bare object. The headers give the object and created or updated, and HitPay doesn't document how the other event names appear.
Workarounds:
- Route on
Hitpay-Event-Objectfirst, then on the object'sstatus. - Log the headers of the first delivery of each event type and confirm your routing against them.
How Hookdeck Can Help: Hookdeck filters can match on headers and body fields together, such as Hitpay-Event-Object and status, to send each kind of event to the right destination.
Some published examples are wrong
The Problem: Code written against the docs' examples doesn't match real deliveries.
Why It Happens: A few of HitPay's examples are copy errors. The transfer.updated example is empty, transfer.scheduled repeats transfer.created, and payment_ repeats the payout.created body, which is a bank payout object rather than a payment request.
Workarounds:
- Capture real deliveries for the events you handle, especially payment requests, before you write parsers.
- Validate the fields you rely on and log the raw body when one is missing.
How Hookdeck Can Help: Hookdeck stores every request with its headers and body, so you build against what HitPay actually sends.
Delivery behaviour is undocumented
The Problem: You don't know whether a failed delivery is retried.
Why It Happens: HitPay's webhook guide doesn't describe timeouts or retries.
Workarounds:
- Return
200as soon as the request is verified and stored. - Reconcile charges and transfers against the HitPay API, so a lost event doesn't leave an order unpaid in your records.
How Hookdeck Can Help: Hookdeck accepts each delivery as it arrives and stores it, then delivers to your service with retries you configure.
Best practices
Verify against the raw body
The signature covers the exact bytes HitPay sent. In Express, use express.raw({ type: '*/*' }). In Next.js, use await request.text(). In FastAPI, use await request.body().
Deduplicate on the object and its state
HitPay sends no delivery ID. Key on the event object, the object's id and its status or updated_at, so a repeat is skipped but a genuine change isn't. See our guide to webhook idempotency.
Handle refunds as updates
charge.updated fires for refunds and partial refunds. Compare refunded_amount with amount, and don't treat the event as a new payment.
Map transfer failure codes
Store the numeric failure code from transfer.failed and map it to an action: a wrong account number needs new details from the recipient, while insufficient balance needs a top-up.
Acknowledge fast and process asynchronously
Verify, store the event, return 200, and update your systems afterwards. See why to process webhooks asynchronously.
Conclusion
HitPay event webhooks are signed with Hitpay-Signature, a hex HMAC-SHA256 of the raw body keyed with the salt of that webhook endpoint, not the API-key salt. The body carries no event name, so route on Hitpay-Event-Object, Hitpay-Event-Type and the object's status, and capture real deliveries before trusting the published examples.
Hookdeck Event Gateway sits in front of your receiver, verifies every HitPay signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle HitPay webhooks reliably in minutes.