Webhook spec
Payload format, signature verification, retry semantics.
Webhook spec
When an order is credited, TriaPay signs a POST request to your configured webhook URL. The bundled DHRU plugin's callback file is the standard receiver. Use this page if you write your own.
Endpoint
Set the URL on the Integration page. With the bundled DHRU plugin:
https://yourdhru.com/modules/gateways/callback/triapay.php
Must be HTTPS and resolve to a public IP. Private, loopback, and link-local addresses are rejected.
Headers
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Payment-Signature |
hex(HMAC_SHA256(webhook_secret, signed_input)) |
X-Payment-Sig-Version |
2 |
X-Payment-Timestamp |
Unix epoch seconds at signing time |
X-Payment-Idempotency |
The order's idempotency key |
X-Payment-Mode |
live or sandbox |
Signed input
The HMAC input is:
{timestamp}\n{idempotency}\n{raw_body}
\n is a single ASCII line feed (0x0A). New integrations must implement v2.
Payload
{
"event": "payment.credited",
"invoiceId": 12345,
"tenantPublicId": "tnt_a3b1c8d4e2f59071",
"amount": "10.051231",
"baseAmount": "10.000000",
"feeDeducted": "0.000000",
"fees": "0",
"txHash": "0xabc...",
"gateway": "triapay",
"asset": "USDT",
"chain": "bep20",
"matchCode": 51231,
"mode": "live",
"ts": 1714579200
}
| Field | Notes |
|---|---|
event |
Always payment.credited. |
invoiceId |
Your DHRU invoice id (echoed from order creation). |
tenantPublicId |
Opaque tenant identifier (tnt_*). Stable per account. |
amount |
Decimal string. Exact amount received. |
baseAmount |
Decimal string. Original invoice amount before any over-fee absorption. |
feeDeducted |
Decimal string. Difference between expected and received amount when the customer overpays the network fee surplus. Usually "0". |
fees |
Decimal string. Currently "0". |
txHash |
Unique transaction reference. On-chain tx hash, Binance Pay transactionId, or Binance off-chain deposit id. Use as the dedup key when crediting. |
gateway |
Always triapay. |
asset |
USDT or USDC. |
chain |
trc20, bep20, or binance_pay. BEP20 and TRC20 also surface Binance internal (off-chain) transfers under the same chain value. |
matchCode |
Numeric match code. |
mode |
live or sandbox. Same value as X-Payment-Mode header. |
ts |
Same value as X-Payment-Timestamp. |
Breaking changes
- (2026-05-10) Webhook payload now uses camelCase for all fields. Custom receivers reading
invoice_id/tx_hash/match_code(snake_case) MUST update toinvoiceId/txHash/matchCode. The bundled DHRU plugin (v2.x or later) supports the new format out of the box. tenantId(raw integer) has been removed and replaced bytenantPublicId, an opaquetnt_*string that is stable per account and safe to log.
Verification (in order)
- Reject if
|now - X-Payment-Timestamp| > 300seconds. - Compute
hex(HMAC_SHA256(webhook_secret, "{ts}\n{idempotency}\n{raw_body}"))over the raw bytes of the body. Do not re-serialize the JSON. - Constant-time compare against
X-Payment-Signature(PHPhash_equals, Nodecrypto.timingSafeEqual, Pythonhmac.compare_digest). - Reject if you have already processed this
X-Payment-Idempotencyvalue.
Expected response
| Status | Meaning |
|---|---|
2xx |
Success. Order finalised. |
4xx / 5xx / network timeout |
Treated as failure. Retried. |
Only the HTTP status matters. Body content is logged for debugging.
Retry policy
Failed deliveries are retried with backoff. If retries don't succeed, the order can be reconciled manually from Admin → Orders → Recredit.
If no webhook URL is set, the order waits until a valid URL is configured.
PHP example
<?php
$secret = 'your_webhook_secret';
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_PAYMENT_SIGNATURE'] ?? '';
$ts = $_SERVER['HTTP_X_PAYMENT_TIMESTAMP'] ?? '0';
$idem = $_SERVER['HTTP_X_PAYMENT_IDEMPOTENCY'] ?? '';
if (abs(time() - intval($ts)) > 300) {
http_response_code(401);
exit('stale timestamp');
}
$signed = $ts . "\n" . $idem . "\n" . $raw;
$expected = hash_hmac('sha256', $signed, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit('bad signature');
}
$payload = json_decode($raw, true);
if (!is_array($payload) || ($payload['event'] ?? '') !== 'payment.credited') {
http_response_code(400);
exit('bad event');
}
if (already_credited($payload['txHash'])) {
echo json_encode(['ok' => true, 'duplicate' => true]);
exit;
}
credit_invoice(
$payload['invoiceId'],
$payload['amount'],
$payload['txHash'],
$payload['gateway']
);
echo json_encode(['ok' => true]);
Node.js example
import { createHmac, timingSafeEqual } from 'crypto';
import express from 'express';
const SECRET = process.env.TRIAPAY_WEBHOOK_SECRET!;
app.post('/webhooks/triapay', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.header('X-Payment-Signature') ?? '';
const ts = parseInt(req.header('X-Payment-Timestamp') ?? '0', 10);
const idem = req.header('X-Payment-Idempotency') ?? '';
if (Math.abs(Date.now() / 1000 - ts) > 300) return res.status(401).end('stale');
const signed = Buffer.concat([Buffer.from(`${ts}\n${idem}\n`), req.body]);
const expected = createHmac('sha256', SECRET).update(signed).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(sig, 'hex');
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).end('bad signature');
}
const payload = JSON.parse(req.body.toString());
// ... idempotency check + credit ...
res.json({ ok: true });
});
Security notes
- Treat your webhook secret like a password. Rotate immediately if leaked.
- Always verify against the raw request body, never the parsed JSON.
- Always use constant-time comparison.
- The webhook URL must be HTTPS and resolve to a public IP.
Need more?
Anything beyond this page is available under partner agreement. Reach us via WhatsApp on the homepage.