Developer tutorial

Verify payment webhooks in Node.js: nine offline checks

A valid signature proves that the body matches the shared secret. It does not prove that an order is fully paid, that a delivery is new, or that fulfilment may run again.

For OmniWallet IPN, verify the HMAC-SHA512 signature in x-omni-sig before using payment data. The sender signs and sends the same serialized JSON body. You can verify its raw bytes, or use the exact canonical JSON procedure in the API reference. Arbitrary JSON formatting is not that procedure.

This offline lab separates signature verification, exact redelivery detection and business validation. It needs no account, network calls, API key or real payment.

1. Run the experiment locally

Save the complete code below as ipn-raw-body-lab.mjs and run node ipn-raw-body-lab.mjs. It uses only built-in Node.js modules. The nine assertions were checked with Node.js v26.1.0; the output also prints your runtime version.

The sample secret and payment identifiers are public, synthetic fixtures. The receiver() function is a teaching model, not an HTTP server or a production payment handler.

ipn-raw-body-lab.mjsNode.js · offline fixtures
// Offline teaching lab, NOT a production receiver or fulfilment policy.
// Run: node docs/growth/examples/ipn-raw-body-lab.mjs
// All payloads and the secret below are synthetic public fixtures.
import { createHmac, createHash, timingSafeEqual } from 'node:crypto';
import assert from 'node:assert/strict';
const secret = 'public-lab-fixture-not-a-production-secret';
const sign = raw => createHmac('sha512', secret).update(raw).digest('hex');
const rows = [];
function receiver() {
  const seen = new Set(); // Demonstrates the restart limitation; never a durable inbox.
  return (raw, signature) => {
    if (typeof signature !== 'string' || !/^[a-f0-9]{128}$/i.test(signature)) return 'reject_signature';
    const expected = Buffer.from(sign(raw), 'hex');
    if (!timingSafeEqual(Buffer.from(signature, 'hex'), expected)) return 'reject_signature';
    let event;
    try { event = JSON.parse(raw.toString('utf8')); } catch { return 'reject_json'; }
    if (!event || typeof event !== 'object' || Array.isArray(event) || !event.payment_id || !event.payment_status) return 'reject_shape';
    const deliveryHash = createHash('sha256').update(raw).digest('hex');
    if (seen.has(deliveryHash)) return 'exact_redelivery';
    seen.add(deliveryHash);
    // No order update, funds movement or fulfilment is performed by this lab.
    return 'record_for_business_validation';
  };
}
const receive = receiver();
const raw = Buffer.from('{"actually_paid":"1","order_id":"注文-例","payment_id":"demo-1","payment_status":"partially_paid"}');
function check(name, result, expected) { assert.equal(result, expected, name); rows.push({ name, result }); }
check('UTF-8 raw body', receive(raw, sign(raw)), 'record_for_business_validation');
check('Same bytes redelivered', receive(raw, sign(raw)), 'exact_redelivery');
const altered = Buffer.from(raw.toString().replace('"1"', '"9"'));
check('Changed amount with old signature', receive(altered, sign(raw)), 'reject_signature');
const pretty = Buffer.from(JSON.stringify(JSON.parse(raw), null, 2));
check('Reserialized JSON with old signature', receive(pretty, sign(raw)), 'reject_signature');
check('Short signature', receive(raw, 'abcd'), 'reject_signature');
check('Non-hex signature', receive(raw, 'z'.repeat(128)), 'reject_signature');
check('Updated amount, same payment/status, new valid signature', receive(altered, sign(altered)), 'record_for_business_validation');
const malformed = Buffer.from('{');
check('Signed malformed JSON', receive(malformed, sign(malformed)), 'reject_json');
check('Receiver restart loses memory deduplication', receiver()(raw, sign(raw)), 'record_for_business_validation');
console.log(JSON.stringify({ runtime: process.version, cases: rows, passed: rows.length, productionReady: false, limitation: 'Exact-byte deduplication only. Use a durable inbox and transactional business idempotency; independently validate payment/order/currency/amount and authoritative status. A valid signature is not proof that an order may be fulfilled.' }, null, 2));

2. Compare the nine expected results

A successful run prints passed: 9 and productionReady: false. These result labels belong to this experiment; they are not OmniWallet API status values.

CaseExpected lab result
UTF-8 raw bodyrecord_for_business_validation
Same bytes redeliveredexact_redelivery
Changed amount with old signaturereject_signature
Reserialized JSON with old signaturereject_signature
Short signaturereject_signature
Non-hex signaturereject_signature
Updated amount, same payment/status, new valid signaturerecord_for_business_validation
Signed malformed JSONreject_json
Receiver restart loses memory deduplicationrecord_for_business_validation

3. Verify bytes before parsing or formatting JSON

Whitespace, key order and character encoding affect the signed bytes. The experiment changes compact JSON into pretty-printed JSON, while keeping the original signature. The fields still mean the same thing, but the signature check fails because the bytes changed.

Capture the raw request body before middleware transforms it. Validate the signature encoding and length, calculate HMAC-SHA512 with your actual IPN secret, and compare equal-length buffers. The lab checks for 128 hexadecimal characters before calling timingSafeEqual; Node.js requires the compared buffers to have the same byte length.

Only after verification does the lab parse JSON and perform its minimal shape check. That check is intentionally incomplete: a real integration must validate the documented schema and business fields too. A signed malformed JSON body is rejected separately from an invalid signature.

4. Separate redelivery from a new payment update

The in-memory Set recognizes identical body bytes using a hash. Restarting the receiver loses that memory, as the final case demonstrates. Even a valid signature does not establish freshness or stop replay by itself.

Do not discard every later message with the same payment_id and payment_status. In the seventh case, a partially paid payment has a changed actually_paid amount and a new valid signature, while its payment ID and status remain the same. That update still needs business validation.

Use a durable inbox and transactional order processing. Make fulfilment idempotent independently of delivery deduplication. Match the payment to your stored order, currency, network, expected amount and authoritative payment state before deciding what to do. The experiment deliberately performs no order update or fulfilment.

5. Before building a real receiver

  • Use HTTPS, a bounded request body and secret storage appropriate to your server. Never use this public fixture secret for a real integration.
  • Implement the documented signature and payload contract, persistent delivery handling, transactional business idempotency and failure recovery.
  • Test invalid signatures, redelivery, receiver restarts, partial payments, overpayments and out-of-order updates in the separate test environment.
  • Keep real credentials and customer payment data out of shared troubleshooting examples. A test-network result is not evidence of live settlement or market availability.

For the complete merchant workflow, see accepting USDT payments. For exact API fields, signature rules and supported payment states, use the API reference and integration guide.

Sources and authorship

Published by the OmniWallet team. AI assisted with drafting and editing; the executable lab and its nine assertions were checked locally. The Japanese version of this experiment is available on our official Zenn account.

Node.js references: createHmac and timingSafeEqual.