Webhook Best Practices
Reliability, deduplication, and production patterns for handling webhooks
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.,
PENDINGafterSUCCEED) - 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 OKimmediately, 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:
- After initiating a payment, start a background timer
- If no webhook arrives within 5 minutes, poll the status endpoint
- Poll with exponential backoff: 5 min → 10 min → 30 min → 1 hour
- 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 + secretImportant:
orderAmountandorderCurrencyare the field names used in the webhook callback payload -- they differ from the request field namesamountandcurrency. 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 OKimmediately before processing - Deduplicate using
transactionId+transactionStatus - Handle out-of-order delivery with status ordering
- Verify
X-Checksumheader 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