Webhooks & API
Your backend talks to NetPay with signed JSON requests, and NetPay calls your backend with signed webhooks whenever money moves.
Before you start
Create your keys
Developers page, Create LIVE key / Create TEST key. Copy the secret when it is shown: it is shown once.
Set your webhook URL
Also on the Developers page. It has to be https and reachable from the internet.
Keep secrets on your server
Never put a key secret or the webhook secret in a browser, an app or a repository.
Signing requests
Every request carries three headers. The signature covers the timestamp, the method, the exact path with its query string, and the exact body bytes you send.
| Header | Value |
|---|---|
X-Api-Key | Your key id. |
X-Timestamp | Current Unix time in seconds. Requests more than 5 minutes off are refused. |
X-Signature | Hex HMAC-SHA256 with your key secret over timestamp + "\n" + METHOD + "\n" + path_and_query + "\n" + raw_body. |
Idempotency-Key | Optional on POST, recommended. See below. |
Sign the exact string you send, not a re-serialised copy of it. For GET the body is an empty string.
const crypto = require('crypto');
async function netpay(method, path, body) {
const raw = body ? JSON.stringify(body) : '';
const ts = String(Math.floor(Date.now() / 1000));
const signature = crypto.createHmac('sha256', process.env.NETPAY_SECRET)
.update(ts + '\n' + method + '\n' + path + '\n' + raw).digest('hex');
const res = await fetch('https://api.netpay.ph' + path, {
method, body: raw || undefined,
headers: { 'Content-Type': 'application/json', 'X-Api-Key': process.env.NETPAY_KEY,
'X-Timestamp': ts, 'X-Signature': signature },
});
return res.json();
}Idempotency
Send Idempotency-Key on every POST; your order number is the natural fit. The same key with the same body returns the first response again (with Idempotent-Replayed: true), so retrying after a timeout never creates a second payin. The same key with a different body answers 409 IDEMPOTENCY_CONFLICT.
Responses and errors
Every response has the same envelope: {"status": "success", "data": {...}} or {"status": "failed", "error": "CODE", "message": "..."}, plus a request_id to quote when you contact us.
| HTTP | error | Meaning |
|---|---|---|
| 401 | AUTH_MISSING, AUTH_INVALID, AUTH_TIMESTAMP | Missing headers, wrong key or signature, clock more than 5 minutes off. |
| 403 | IP_NOT_ALLOWED, MERCHANT_INACTIVE | Your IP allowlist or account status. |
| 409 | DUPLICATE_ORDER, DUPLICATE_PAYOUT, IDEMPOTENCY_CONFLICT, INSUFFICIENT_BALANCE | A repeat, or not enough available balance. |
| 422 | VALIDATION_ERROR, AMOUNT_OUT_OF_RANGE, AMOUNT_BELOW_FEE, INVALID_CHANNEL, INVALID_BANK, CHANNEL_UNAVAILABLE | Fix the request; retrying the same one will not help. |
| 429 | RATE_LIMITED | More than 600 requests per minute. |
| 502 | UPSTREAM_UNAVAILABLE | The processor did not accept the order. Retry later with the same Idempotency-Key. |
Other endpoints
| Call | Returns |
|---|---|
GET /v1/balance | Available, pending, frozen and total. |
GET /v1/channels | Channels open to you now, with limits and your fee tiers. |
GET /v1/banks | Payout banks and e-wallets with their bank_code. |
GET /v1/fees/quote?direction=PAYIN&channel=QRPH&amount=1500 | The fee you would pay right now. |
GET /v1/ping | Signature test. |
Receiving webhooks
When a payin, payout or claim changes, NetPay posts to your webhook URL. Answer any 2xx within 10 seconds and do the slow work afterwards.
POST https://yourshop.example/netpay/webhook
Content-Type: application/json
X-NetPay-Event: payin.succeeded
X-NetPay-Event-Id: evt_6a1f...
X-NetPay-Timestamp: 1762574400
X-NetPay-Signature: v1=<hex HMAC-SHA256 of timestamp + "." + raw body>
{
"id": "evt_6a1f...",
"type": "payin.succeeded",
"created": "2026-11-08T12:01:00+08:00",
"mode": "LIVE",
"data": { "payin_no": "PI26110800AB12CD34EF", "merchant_order_id": "ORD-10042",
"status": "SUCCEEDED", "amount": "1250.00", "fee": "25.00", "net_amount": "1225.00", ... }
}| Event | Sent when |
|---|---|
payin.succeeded | A payin is paid. |
payin.failed / payin.expired / payin.cancelled | A payin ends without payment. |
payout.succeeded | A payout is delivered. |
payout.failed / payout.rejected | A payout fails or is declined; its hold is released. |
claim.approved / claim.rejected | A settlement claim is decided. |
Recompute the HMAC-SHA256 of the timestamp, a dot and the raw body with your webhook secret, and compare it to X-NetPay-Signature. Anyone can post to your URL; the signature is what makes it ours.
const crypto = require('crypto');
// rawBody: the exact bytes you received, before any JSON parsing
function isFromNetPay(req, rawBody) {
const ts = req.headers['x-netpay-timestamp'] || '';
const expected = 'v1=' + crypto.createHmac('sha256', process.env.NETPAY_WEBHOOK_SECRET)
.update(ts + '.' + rawBody).digest('hex');
const got = req.headers['x-netpay-signature'] || '';
return got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}Anything other than a 2xx is retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 3, 6, 12 and 24 hours, and you can resend any delivery from Webhook Logs. Key your handler on the event id and make a repeat a no-op.
