Skip to main content
← Back to documentation

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 to invoiceId / 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 by tenantPublicId, an opaque tnt_* string that is stable per account and safe to log.

Verification (in order)

  1. Reject if |now - X-Payment-Timestamp| > 300 seconds.
  2. 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.
  3. Constant-time compare against X-Payment-Signature (PHP hash_equals, Node crypto.timingSafeEqual, Python hmac.compare_digest).
  4. Reject if you have already processed this X-Payment-Idempotency value.

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.