Skip to content
API DocsDocs

Webhooks Quick Start

Set up a webhook endpoint to verify and route transaction callbacks

4 min readUpdated Aug 3, 2026

Set up a minimal webhook handler: respond 200 OK immediately, verify the signature, deduplicate, then process.

#Prerequisites

  • A publicly accessible HTTPS endpoint
  • Your merchant secret key (from the Exirom dashboard)
  • Node.js example dependencies: npm install express lossless-json

The example uses a dedicated callback URL ending in the callback type: apm-payment, card-payment, card-payout, card-refund, or apm-payout. Configure the matching URL for each originating API flow. This avoids guessing APM payment versus APM payout from payload fields.

APM-payment callbacks retain their existing accountId|orderAmount|orderCurrency|transactionId signature. The other four callback types use transactionId|requestId|transactionStatus. Both use HMAC-SHA256 and standard Base64.

The Node.js example uses lossless-json so orderAmount: 200.0 remains "200.0" for signing. This works on Node.js 20, where JSON.parse does not provide source context to the reviver.

#Minimal Webhook Handler

const crypto = require('crypto');
const express = require('express');
const { isLosslessNumber, parse: parseLossless } = require('lossless-json');
const app = express();
 
const callbackTypes = new Set([
  'apm-payment', 'card-payment', 'card-payout', 'card-refund', 'apm-payout'
]);
 
app.post('/webhooks/:callbackType', express.text({ type: 'application/json' }), async (req, res) => {
  // 1. Respond 200 immediately — prevents Exirom from retrying
  res.status(200).send('OK');
 
  // 2. Verify signature
  const received = req.headers['x-checksum'];
  const { callbackType } = req.params;
  if (!callbackTypes.has(callbackType)) return;
 
  const p = JSON.parse(req.body);
  const checksumPayload = callbackType === 'apm-payment'
    ? parseLossless(req.body)
    : p;
  const checksumField = value => isLosslessNumber(value) ? value.toString() : value;
 
  const fields = callbackType === 'apm-payment'
    ? [
        checksumPayload.accountId,
        checksumPayload.orderAmount,
        checksumPayload.orderCurrency,
        checksumPayload.transactionId,
      ].map(checksumField)
    : [p.transactionId, p.requestId, p.transactionStatus];
  const raw = fields.join('|');
 
  const expected = crypto
    .createHmac('sha256', process.env.MERCHANT_SECRET)
    .update(raw)
    .digest('base64');
  const expectedBytes = Buffer.from(expected);
  const receivedBytes = Buffer.from(received || '');
 
  if (expectedBytes.length !== receivedBytes.length
      || !crypto.timingSafeEqual(expectedBytes, receivedBytes)) {
    console.error('Invalid signature — ignoring callback');
    return;
  }
 
  // 3. Deduplicate
  const callbackIdentity = callbackType === 'card-refund'
    ? `${p.transactionId}:${p.requestId}`
    : p.transactionId;
  const key = `${callbackIdentity}:${p.transactionStatus}`;
  const exists = await db.webhookLog.findUnique({ where: { key } });
  if (exists) return;
  await db.webhookLog.create({ data: { key } });
 
  // 4. Dispatch to flow-specific business logic
  await enqueueVerifiedCallback({ callbackType, payload: p });
});

#What the Handler Does

StepWhy
Respond 200 firstExirom retries if it doesn't receive 200 within 10 seconds
Verify X-ChecksumPrevents forged callbacks from triggering fulfillment
Deduplicate using the callback identity and statusCard refunds require transactionId + requestId + transactionStatus; other callbacks use transaction ID + status
Dispatch by callback type and statusPayment, payout, and refund results require different business actions

#Key Callback Fields

FieldDescription
transactionIdTransaction identity. For card refunds, this is the original card-payment ID, not refundId
requestIdMerchant correlation ID. For card refunds, this signed field identifies the individual refund
transactionStatusSUCCEED, FAILED, PENDING, PROCESSING, CUSTOMER_VERIFICATION, REFUNDED
declineCodeSet when transactionStatus is FAILED — see Decline Codes Reference
paymentMethodQuery param appended by Exirom: card or apm

#Callback Headers

HeaderDescription
X-ChecksumSignature used to verify callback authenticity
X-Error-Code-DescriptionOptional decline description. Sent only for enabled merchants when transactionStatus is FAILED, declineCode is present, and a decline reason is available

For card payment, card payout, card refund, and APM payout callbacks, amount, currency, and all other payload fields are intentionally outside the checksum. Validate unsigned fields separately before using them.

For card refunds, verify transactionId|requestId|transactionStatus, then deduplicate using the same three fields. refundId is not signed.

#Next Steps

Was this helpful?