Guide: Webhook to SMS
Guide: Webhook to SMS
A focused, end-to-end guide covering the pipeline from an order status change to an SMS delivered to the customer's phone. This guide provides production-ready code you can adapt for your own app.
The Pipeline
1. Order status changes (e.g., pending -> confirmed)
|
v
2. Platform emits event (order.status_changed)
|
v
3. Inngest delivers webhook (HMAC-signed POST to your endpoint)
|
v
4. App receives & verifies (signature check)
|
v
5. App debits wallet (billing API call)
|
v
6. App sends SMS (BulkSMS API call)
Complete Express Handler
This is a self-contained Express server that handles the entire pipeline:
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SIGNING_SECRET; // whsec_...
// Status to SMS template mapping
const STATUS_TEMPLATES = {
confirmed:
'Hi {{customer_name}}, your order #{{order_number}} has been confirmed! We will process it shortly.',
processing:
'Hi {{customer_name}}, your order #{{order_number}} is being prepared.',
shipped:
'Hi {{customer_name}}, your order #{{order_number}} has been shipped! Tracking: {{tracking_id}}',
delivered:
'Hi {{customer_name}}, your order #{{order_number}} has been delivered. Thank you for shopping with {{store_name}}!',
};
// --- Utility Functions ---
function verifySignature(rawBody, signature, timestamp, secret) {
const signedContent = `${timestamp}.${rawBody}`;
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(signedContent).digest('hex');
if (expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
function renderTemplate(template, variables) {
return template.replace(
/\{\{(\w+)\}\}/g,
(_, key) => variables[key] || ''
);
}
// --- Webhook Endpoint ---
app.post(
'/webhooks',
express.raw({ type: 'application/json' }),
async (req, res) => {
// 1. Verify HMAC signature
const signature = req.headers['x-selorax-signature'];
const timestamp = req.headers['x-selorax-timestamp'];
if (!signature || !timestamp || !verifySignature(req.body, signature, timestamp, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// 2. Only handle order.status_changed
if (event.event_topic !== 'order.status_changed') {
return res.status(200).send('OK');
}
// 3. Extract data and find matching template
const {
status,
customer_name,
customer_phone,
order_number,
tracking_id,
store_name,
} = event.data;
const template = STATUS_TEMPLATES[status];
if (!template) {
return res.status(200).send('OK');
}
// 4. Render the SMS message
const message = renderTemplate(template, {
customer_name,
customer_phone,
order_number,
tracking_id,
store_name,
});
// 5. Debit merchant's wallet
const debitRes = await fetch(
'https://api.selorax.io/api/apps/v1/billing/wallet/debit',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': process.env.CLIENT_ID,
'X-Client-Secret': process.env.CLIENT_SECRET,
'X-Store-Id': String(event.store_id),
},
body: JSON.stringify({
amount: 2.5,
description: `SMS to ${customer_phone}`,
}),
}
);
if (!debitRes.ok) {
console.error('Wallet debit failed:', await debitRes.json());
// Return 200 to prevent Inngest from retrying the webhook
return res.status(200).send('OK');
}
// 6. Send SMS via BulkSMS API
try {
await fetch('http://bulksmsbd.net/api/smsapi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: process.env.SMS_API_KEY,
senderid: 'SeloraX',
number: customer_phone,
message: message,
}),
});
} catch (err) {
console.error('SMS delivery failed:', err.message);
// SMS failed but wallet was already debited.
// Log for reconciliation; do not return non-200.
}
res.status(200).send('OK');
}
);
app.listen(5002, () => console.log('Webhook handler running on port 5002'));Step-by-Step Breakdown
1. Signature Verification
Every webhook from SeloraX includes an X-SeloraX-Signature header and an X-SeloraX-Timestamp header. The signature is an HMAC-SHA256 digest of {timestamp}.{raw_body}, prefixed with sha256=.
function verifySignature(rawBody, signature, timestamp, secret) {
const signedContent = `${timestamp}.${rawBody}`;
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(signedContent).digest('hex');
if (expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}Important: Use express.raw({ type: 'application/json' }) to get the raw body as a Buffer. If you parse the body as JSON first, the signature will not match because JSON serialization may alter whitespace or key ordering.
Important: Use crypto.timingSafeEqual for signature comparison to prevent timing attacks.
Important: The timestamp is included in the signed content to prevent replay attacks. You should also verify that the timestamp is recent (within 5 minutes). See Receiving Webhooks for full details.
2. Event Filtering
The platform sends all subscribed webhook topics to the same endpoint. Filter for the events you care about:
if (event.event_topic !== 'order.status_changed') {
return res.status(200).send('OK');
}Always return 200 for events you do not handle. A non-200 response will cause Inngest to retry delivery.
3. Template Matching
Match the order's new status to a template:
const template = STATUS_TEMPLATES[status];
if (!template) return res.status(200).send('OK');In the SeloraX Messaging app, templates are stored in the database and configured by the merchant. The hardcoded STATUS_TEMPLATES object here is a simplified example.
4. Variable Substitution
Replace {{variable}} placeholders with actual values from the webhook payload:
function renderTemplate(template, variables) {
return template.replace(
/\{\{(\w+)\}\}/g,
(_, key) => variables[key] || ''
);
}Missing variables are replaced with an empty string. In production, you may want to log a warning when a variable is missing.
5. Wallet Debit
Before sending the SMS, debit the merchant's wallet. This ensures the merchant has sufficient balance and that you are compensated for the SMS cost.
const debitRes = await fetch(
'https://api.selorax.io/api/apps/v1/billing/wallet/debit',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': process.env.CLIENT_ID,
'X-Client-Secret': process.env.CLIENT_SECRET,
'X-Store-Id': String(event.store_id),
},
body: JSON.stringify({
amount: 2.5,
description: `SMS to ${customer_phone}`,
}),
}
);If the debit fails (insufficient balance or other error), skip the SMS. Return 200 to prevent webhook retries -- the failure is a business logic issue, not a delivery issue.
6. SMS Delivery
Send the rendered message via the BulkSMS API:
await fetch('http://bulksmsbd.net/api/smsapi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: process.env.SMS_API_KEY,
senderid: 'SeloraX',
number: customer_phone,
message: message,
}),
});Template Variable Reference
These variables are available in the event.data payload, populated by the platform's buildOrderEventPayload() helper:
| Variable | Source | Example |
|---|---|---|
{{order_number}} | event.data.order_number | ORD-1234 |
{{customer_name}} | event.data.customer_name | John Doe |
{{customer_phone}} | event.data.customer_phone | +8801712345678 |
{{tracking_id}} | event.data.tracking_id | TRACK123 |
{{store_name}} | event.data.store_name | My Shop |
{{status}} | event.data.status | confirmed |
Webhook Payload Structure
A typical order.status_changed webhook payload:
{
"event_topic": "order.status_changed",
"store_id": 22,
"timestamp": "2026-02-28T10:30:00.000Z",
"data": {
"order_id": 4567,
"order_number": "ORD-1234",
"status": "confirmed",
"previous_status": "pending",
"customer_name": "John Doe",
"customer_phone": "+8801712345678",
"tracking_id": null,
"store_name": "My Shop"
}
}Note that tracking_id may be null for statuses before shipping. Your templates should handle this gracefully (the renderTemplate function replaces missing values with empty strings).
Error Handling Best Practices
Always Return 200
Return 200 OK for any webhook you have received and processed (or intentionally skipped). Non-200 responses trigger retries from Inngest, which can lead to duplicate SMS sends.
// Good: return 200 even when skipping
if (!template) return res.status(200).send('OK');
// Good: return 200 even when wallet debit fails
if (!debitRes.ok) {
console.error('Wallet debit failed');
return res.status(200).send('OK');
}Idempotency
Webhooks may be delivered more than once. To prevent duplicate SMS sends, track processed events:
const eventId = event.id || `${event.store_id}-${event.data.order_id}-${event.data.status}`;
// Check if already processed
const [existing] = await db.query(
'SELECT id FROM processed_webhooks WHERE event_id = ?',
[eventId]
);
if (existing.length > 0) return res.status(200).send('OK');
// Process and mark as handled
await db.query(
'INSERT INTO processed_webhooks (event_id) VALUES (?)',
[eventId]
);Wallet Debit Before SMS
Always debit the wallet before sending the SMS. If the SMS fails after debiting, you can issue a credit or log it for reconciliation. If you send the SMS first and the debit fails, you have delivered a service without payment.
Environment Variables
| Variable | Description |
|---|---|
WEBHOOK_SIGNING_SECRET | HMAC secret for verifying webhook signatures (whsec_...) |
CLIENT_ID | Your app's client ID (sx_app_...) |
CLIENT_SECRET | Your app's client secret (sx_secret_...) |
SMS_API_KEY | BulkSMS API key |