Skip to content
API DocsDocs

Webhook Best Practices

Reliability, deduplication, and production patterns for handling webhooks

5 min readUpdated Mar 26, 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. Use the combination to deduplicate:

// Node.js — idempotent webhook handler
app.post('/webhooks/payment', express.json(), async (req, res) => {
  // Always respond 200 immediately to prevent retries
  res.status(200).send('OK');
 
  const { transactionId, transactionStatus } = req.body;
  const dedupeKey = `${transactionId}:${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() },
  });
}

#Polling as Fallback

If your webhook endpoint was down or all 5 retries failed, use the status endpoints to recover:

Card transactions:

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

APM transactions:

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

Recommended polling strategy:

  1. After initiating a payment, start a background timer
  2. If no webhook arrives within 5 minutes, poll the status 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/status/${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.

Card vs APM Checksum Fields:

  • Card callbacks: mid + orderAmount + orderCurrency + transactionId + secret
  • APM callbacks: accountId + amount + currency + transactionId + secret

Important: orderAmount and orderCurrency are the field names used in the webhook callback payload -- they differ from the request field names amount and currency. Always use the callback payload field names when computing the checksum.

The field names differ because Card and APM use different DTOs. Use the exact field names from the callback payload you receive.

const crypto = require('crypto');
 
function verifyCardWebhook(payload, receivedChecksum, merchantSecret) {
  const data = `${payload.mid}|${payload.orderAmount}|${payload.orderCurrency}|${payload.transactionId}`;
  const computed = crypto
    .createHmac('sha256', merchantSecret)
    .update(data)
    .digest('base64');
 
  return computed === receivedChecksum;
}
 
function verifyApmWebhook(payload, receivedChecksum, merchantSecret) {
  const data = `${payload.accountId}|${payload.amount}|${payload.currency}|${payload.transactionId}`;
  const computed = crypto
    .createHmac('sha256', merchantSecret)
    .update(data)
    .digest('base64');
 
  return computed === receivedChecksum;
}
 
// In your handler:
const checksum = req.headers['x-checksum'];
const { paymentMethod } = req.query;
const isValid = paymentMethod === 'apm'
  ? verifyApmWebhook(req.body, checksum, MERCHANT_SECRET)
  : verifyCardWebhook(req.body, 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.

#Interactive Checksum Verifier

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

Webhook HMAC Validator

Paste a webhook payload and your merchant secret to verify the HMAC-SHA256 checksum.

#Production Checklist

  • Respond 200 OK immediately before processing
  • Deduplicate using transactionId + transactionStatus
  • 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?