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. 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 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() },
});
}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:
- After initiating a payment, start a background timer
- If no webhook arrives within 5 minutes, poll the info 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/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.
| Callback | Signed fields, in order |
|---|---|
| APM payment | accountId, orderAmount, orderCurrency, transactionId |
| Card payment, card payout, card refund, APM payout | transactionId, 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
transactionStatusisFAILEDdeclineCodeis 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 OKimmediately 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-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