# 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 `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

| 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_request.completed`, `payment_request.failed` | A payment request is paid, or receives an error |
| Recurring billing | `recurring_billing.method_attached`, `recurring_billing.method_detached`, `recurring_billing.subscription_updated` | 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_request.completed`, appear in the headers. For those, look at the object's `status` too. Here is HitPay's `payout.created` example:

```json
{
  "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.

```javascript
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:

```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](/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](/docs/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](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) sits in front of your receiver, verifies every HitPay signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle HitPay webhooks reliably in minutes.