Webhooks Quick Start
Set up a webhook endpoint to verify and route transaction callbacks
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
| Step | Why |
|---|---|
Respond 200 first | Exirom retries if it doesn't receive 200 within 10 seconds |
Verify X-Checksum | Prevents forged callbacks from triggering fulfillment |
| Deduplicate using the callback identity and status | Card refunds require transactionId + requestId + transactionStatus; other callbacks use transaction ID + status |
| Dispatch by callback type and status | Payment, payout, and refund results require different business actions |
#Key Callback Fields
| Field | Description |
|---|---|
transactionId | Transaction identity. For card refunds, this is the original card-payment ID, not refundId |
requestId | Merchant correlation ID. For card refunds, this signed field identifies the individual refund |
transactionStatus | SUCCEED, FAILED, PENDING, PROCESSING, CUSTOMER_VERIFICATION, REFUNDED |
declineCode | Set when transactionStatus is FAILED — see Decline Codes Reference |
paymentMethod | Query param appended by Exirom: card or apm |
#Callback Headers
| Header | Description |
|---|---|
X-Checksum | Signature used to verify callback authenticity |
X-Error-Code-Description | Optional 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
- Webhook Overview — full payload schema and lifecycle diagram
- Callback Identification — routing Card vs APM vs HPP callbacks
- Webhook Best Practices — deduplication patterns, out-of-order handling, polling fallback
- Callback Retry Mechanism — retry schedule and backoff behavior