Gareth Wilson Gareth Wilson

Guide to HitPay Webhooks: Features and Best Practices

Published


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 POST for each event. Each request has a Hitpay-Signature header: 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 hmac field 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

FeatureDetails
ConfigurationDashboard, Developers > Webhooks, or POST /v1/webhook-events
URL rulesHTTPS, resolving to a public address. Loopback and private addresses are rejected
Event headersHitpay-Event-Object and Hitpay-Event-Type
Signature headerHitpay-Signature
SchemeHMAC-SHA256, bare lowercase hex
Signed contentThe raw JSON body, nothing else
SecretA salt per webhook endpoint, on its detail view
BodyThe object itself, with no envelope or event name
User agentHitPay v2.0
Retries, timeout, source IPsNot documented

Common events

AreaEventsFires when
Chargescharge.created, charge.updated, charge.failedA payment completes, is refunded or partially refunded, or fails
Ordersorder.created, order.updatedAn order is created, or its status changes
Invoicesinvoice.created, invoice.updatedAn invoice is created or updated
Payoutspayout.createdA payout completes
Transferstransfer.created, transfer.updated, transfer.scheduled, transfer.processing, transfer.paid, transfer.failed, transfer.canceledA transfer moves through its lifecycle
Payment requestspayment_request.completed, payment_request.failedA payment request is paid, or receives an error
Recurring billingrecurring_billing.method_attached, recurring_billing.method_detached, recurring_billing.subscription_updatedA 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_request.completed, 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

  1. 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.
  2. Open the webhook's detail view and copy its salt. Store it server-side, for example as HITPAY_WEBHOOK_SALT.
  3. 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-Signature header 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 hmac field and no Hitpay-Signature header, 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-Object first, then on the object's status.
  • 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_request.completed 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 200 as 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.