Skip to content
API DocsDocs

Webhook Best Practices

Reliability, deduplication, and production patterns for handling webhooks

7 min readUpdated Aug 3, 2026

Exirom sends webhook callbacks for every transaction status change. In production, you must handle duplicate delivery, out-of-order callbacks, and missed notifications gracefully. This guide covers the patterns you need.

#Delivery Guarantees

Exirom webhooks provide at-least-once delivery:

  • Every callback is retried up to 5 times with exponential backoff (2 min → 4 → 8 → 16 → 32 min)
  • The same callback may arrive more than once (network retries, timeouts)
  • Callbacks for a single transaction may arrive out of order (e.g., PENDING after SUCCEED)
  • If all 5 retries fail, the callback is dropped — you must poll for status

#Deduplication

Every callback includes a transactionId and transactionStatus. For card refunds, transactionId is the original card-payment ID, so include the signed requestId to distinguish separate refunds against the same payment.

// Node.js — idempotent webhook handler
app.post('/webhooks/:callbackType', express.json(), async (req, res) => {
  // Always respond 200 immediately to prevent retries
  res.status(200).send('OK');
 
  const { transactionId, requestId, transactionStatus } = req.body;
  const callbackIdentity = req.params.callbackType === 'card-refund'
    ? `${transactionId}:${requestId}`
    : transactionId;
  const dedupeKey = `${callbackIdentity}:${transactionStatus}`;
 
  // Check if already processed (use your database)
  const exists = await db.webhookLog.findUnique({ where: { dedupeKey } });
  if (exists) {
    console.log(`Duplicate webhook ignored: ${dedupeKey}`);
    return;
  }
 
  // Record before processing (prevents race conditions)
  await db.webhookLog.create({ data: { dedupeKey, receivedAt: new Date() } });
 
  // Now process the callback
  await processCallback(req.body);
});

Key rule: Always respond 200 OK immediately, then process asynchronously. If your handler returns an error or times out, Exirom retries — causing duplicates.

#Handling Out-of-Order Callbacks

Transaction status follows a defined progression. Use status ordering to prevent stale updates:

NEW → PENDING → PROCESSING → SUCCEED / FAILED / REFUNDED / CHARGEBACK

Assign each status a numeric weight and only process callbacks that move forward:

const STATUS_ORDER = {
  NEW: 1,
  PENDING: 2,
  PROCESSING: 3,
  CUSTOMER_VERIFICATION: 3,
  SUCCEED: 10,
  FAILED: 10,
  REFUNDED: 11,
  CHARGEBACK: 12,
};
 
async function processCallback(payload) {
  const { transactionId, transactionStatus } = payload;
  const current = await db.transactions.findUnique({ where: { transactionId } });
 
  if (current && STATUS_ORDER[current.status] >= STATUS_ORDER[transactionStatus]) {
    console.log(`Stale callback ignored: ${transactionId} already at ${current.status}`);
    return;
  }
 
  await db.transactions.update({
    where: { transactionId },
    data: { status: transactionStatus, updatedAt: new Date() },
  });
}

For card-refund callbacks, scope status tracking by original-payment transactionId plus signed requestId. Do not apply refund status ordering to the original payment record using transactionId alone.

#Polling as Fallback

If your webhook endpoint was down or all 5 retries failed, use the info endpoints to recover — they return the transaction's status along with declineCode on failure:

Card transactions:

GET /api/v1/payments/card/info/{transactionId}

APM transactions:

GET /api/v1/payments/apm/info/{transactionId}

Recommended polling strategy:

  1. After initiating a payment, start a background timer
  2. If no webhook arrives within 5 minutes, poll the info endpoint
  3. Poll with exponential backoff: 5 min → 10 min → 30 min → 1 hour
  4. Stop polling when you reach a terminal status (SUCCEED, FAILED, REFUNDED)
async function pollUntilFinal(transactionId, token) {
  const delays = [5 * 60, 10 * 60, 30 * 60, 60 * 60]; // seconds
 
  for (const delay of delays) {
    await sleep(delay * 1000);
 
    const res = await fetch(
      `https://sandbox.api.exirom.com/api/api/v1/payments/card/info/${transactionId}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    const data = await res.json();
 
    if (['SUCCEED', 'FAILED', 'REFUNDED', 'CHARGEBACK'].includes(data.transactionStatus)) {
      return data;
    }
  }
  // If still pending after all polls, alert your ops team
  throw new Error(`Transaction ${transactionId} stuck in non-terminal state`);
}

#Verifying Webhook Signatures

Every webhook includes an X-Checksum header. Always verify before processing.

CallbackSigned fields, in order
APM paymentaccountId, orderAmount, orderCurrency, transactionId
Card payment, card payout, card refund, APM payouttransactionId, requestId, transactionStatus

Join the fields with |, sign the resulting string with HMAC-SHA256 and your merchant secret, then encode the bytes with standard Base64. The APM-payment contract is unchanged.

Select the formula from the API flow that originated the callback. Do not infer APM payment versus APM payout from the presence of accountId.

Preserve the raw numeric representation of APM-payment orderAmount. Capture the UTF-8 request body before JSON middleware; req.rawBody below represents those captured bytes. The JavaScript example uses lossless-json and works on Node.js 20. Install it with npm install lossless-json.

const crypto = require('crypto');
const { isLosslessNumber, parse: parseLossless } = require('lossless-json');
 
function callbackFields(payload, callbackType) {
  const fields = callbackType === 'apm-payment'
    ? [payload.accountId, payload.orderAmount, payload.orderCurrency, payload.transactionId]
    : [payload.transactionId, payload.requestId, payload.transactionStatus];
  return fields.map(value => isLosslessNumber(value) ? value.toString() : value);
}
 
function verifyWebhook(rawPayload, callbackType, receivedChecksum, merchantSecret) {
  const payload = parseLossless(rawPayload);
  const data = callbackFields(payload, callbackType).join('|');
  const computed = crypto
    .createHmac('sha256', merchantSecret)
    .update(data)
    .digest('base64');
  const computedBytes = Buffer.from(computed);
  const receivedBytes = Buffer.from(receivedChecksum || '');
  return computedBytes.length === receivedBytes.length
    && crypto.timingSafeEqual(computedBytes, receivedBytes);
}
 
// In your handler:
const checksum = req.headers['x-checksum'];
const callbackType = 'card-payment'; // Derive from the API flow or dedicated callback URL.
const isValid = verifyWebhook(req.rawBody, callbackType, checksum, MERCHANT_SECRET);
 
if (!isValid) {
  console.error('Invalid webhook signature — possible spoofing');
  return; // Do NOT process
}

See the full Checksum Authentication Guide for field ordering details.

For card payment, card payout, card refund, and APM payout callbacks, amount, currency, and all fields other than the three listed above are intentionally unsigned. Validate unsigned fields separately before using them.

#Decline Description Header

For declined transactions, some merchant accounts receive an additional X-Error-Code-Description callback header.

This header is sent only when all conditions are met:

  • The merchant account is enabled for the header
  • transactionStatus is FAILED
  • declineCode is present
  • A decline reason is available

Use this header as display or logging context only. Continue to base payment logic on transactionStatus and declineCode.

#Interactive Checksum Verifier

Paste a webhook payload and your merchant secret to verify a checksum locally — no server needed:

Webhook HMAC Validator

Select the callback type, then paste its payload and your merchant secret. Verification runs locally in your browser.

#Production Checklist

  • Respond 200 OK immediately before processing
  • Deduplicate card refunds using transactionId + requestId + transactionStatus; other callbacks use transaction ID + status
  • Handle out-of-order delivery with status ordering
  • Verify X-Checksum header on every callback
  • Implement polling fallback for missed webhooks
  • Log all received webhooks (including duplicates) for audit
  • Set up alerting for webhook verification failures
  • Test with callback retry simulation in sandbox
Was this helpful?