Receiving Webhooks
Receiving Webhooks
When an event occurs on the platform, SeloraX sends an HTTP POST request to your webhook endpoint. This page covers the request format, how to verify the HMAC signature, replay attack prevention, and best practices for processing events.
Request Format
Every webhook delivery is an HTTP POST with the following structure:
Headers
POST https://your-app.com/webhooks
Content-Type: application/json
X-SeloraX-Signature: sha256=a1b2c3d4e5f6...
X-SeloraX-Timestamp: 1709107200
X-SeloraX-Webhook-Event: order.status_changed
X-SeloraX-Webhook-Event-Id: 550e8400-e29b-41d4-a716-446655440000
X-SeloraX-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
X-SeloraX-Delivery-Id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
User-Agent: SeloraX-Webhook/1.0| Header | Description |
|---|---|
X-SeloraX-Signature | HMAC-SHA256 signature of {timestamp}.{body}, prefixed with sha256=. See Verifying the Signature. |
X-SeloraX-Timestamp | Unix timestamp (seconds) of when the delivery was signed. Used for replay attack prevention. |
X-SeloraX-Webhook-Event | The event topic (e.g., order.status_changed) |
X-SeloraX-Webhook-Event-Id | Unique event ID (UUID). Use for deduplication. |
X-SeloraX-Idempotency-Key | Same as event ID. Can be used for idempotent processing. |
X-SeloraX-Delivery-Id | Unique delivery ID (UUID). Differs from event ID — each retry gets a new delivery ID. |
User-Agent | Always SeloraX-Webhook/1.0 |
Body
{
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"event_topic": "order.status_changed",
"store_id": 22,
"timestamp": "2024-02-28T10:00:00.000Z",
"data": {
"order_id": 1234,
"order_number": "ORD-1234",
"customer_name": "John Doe",
"customer_phone": "+8801712345678",
"total": 1500.0,
"tracking_id": "TRACK123",
"store_name": "My Shop",
"status": "confirmed",
"previous_status": "pending"
}
}Verifying the Signature
Always verify the webhook signature before processing the event. This confirms the request came from SeloraX and was not tampered with in transit.
The signature is computed by signing the string {timestamp}.{body} with your signing secret:
signed_content = "{unix_timestamp}.{raw_request_body}"
signature = HMAC-SHA256(signed_content, signing_secret) -> hex digest, prefixed with "sha256="
Your signing secret is the whsec_... value returned when the webhook subscription was created.
Replay Attack Prevention
The timestamp is included in the signed content to prevent replay attacks. Always verify that the timestamp is recent (within 5 minutes) in addition to checking the signature. An attacker cannot reuse an old delivery because the timestamp would be stale.
Node.js (Express)
const crypto = require("crypto");
const express = require("express");
const app = express();
const WEBHOOK_TOLERANCE_SECONDS = 300; // 5 minutes
/**
* Verify the webhook signature and timestamp.
* Signs "timestamp.body" to prevent replay attacks.
* Uses timing-safe comparison to prevent timing attacks.
*/
function verifyWebhook(rawBody, signatureHeader, timestampHeader, secret) {
// 1. Check timestamp freshness (reject deliveries older than 5 minutes)
const timestamp = Number(timestampHeader);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > WEBHOOK_TOLERANCE_SECONDS) {
return false; // Too old — possible replay attack
}
// 2. Compute expected signature: HMAC of "timestamp.body"
const signedContent = `${timestamp}.${rawBody}`;
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(signedContent).digest("hex");
// 3. Timing-safe comparison
if (expected.length !== signatureHeader.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader),
);
}
// IMPORTANT: Use express.raw() to get the raw body as a Buffer.
// If you parse JSON first, the signature will not match.
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-selorax-signature"];
const timestamp = req.headers["x-selorax-timestamp"];
const secret = process.env.WEBHOOK_SIGNING_SECRET; // whsec_...
if (
!signature ||
!timestamp ||
!verifyWebhook(req.body, signature, timestamp, secret)
) {
return res.status(401).json({ error: "Invalid signature" });
}
// Signature verified -- parse and process
const event = JSON.parse(req.body);
console.log(`Received ${event.event_topic}:`, event.data);
// Respond 200 immediately, then process asynchronously
res.status(200).send("OK");
// Process the event (queue it, update database, etc.)
handleWebhookEvent(event).catch((err) => {
console.error("Webhook processing error:", err);
});
});Python (Flask)
import hmac
import hashlib
import time
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_..." # from environment
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_webhook(raw_body: bytes, signature_header: str, timestamp_header: str, secret: str) -> bool:
# 1. Check timestamp freshness
try:
timestamp = int(timestamp_header)
except (TypeError, ValueError):
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False # Too old -- possible replay attack
# 2. Compute expected signature: HMAC of "timestamp.body"
signed_content = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(
secret.encode(), signed_content, hashlib.sha256
).hexdigest()
# 3. Timing-safe comparison
return hmac.compare_digest(expected, signature_header)
@app.route("/webhooks", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-SeloraX-Signature", "")
timestamp = request.headers.get("X-SeloraX-Timestamp", "")
raw_body = request.get_data()
if not verify_webhook(raw_body, signature, timestamp, WEBHOOK_SECRET):
return jsonify({"error": "Invalid signature"}), 401
event = request.get_json()
print(f"Received {event['event_topic']}: {event['data']}")
# Process asynchronously (e.g., queue to Celery)
return "OK", 200PHP
<?php
$secret = getenv('WEBHOOK_SIGNING_SECRET'); // whsec_...
$toleranceSeconds = 300; // 5 minutes
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SELORAX_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_SELORAX_TIMESTAMP'] ?? '';
// 1. Check timestamp freshness
if (abs(time() - intval($timestamp)) > $toleranceSeconds) {
http_response_code(401);
echo json_encode(['error' => 'Timestamp too old']);
exit;
}
// 2. Compute expected signature: HMAC of "timestamp.body"
$signedContent = $timestamp . '.' . $rawBody;
$expected = 'sha256=' . hash_hmac('sha256', $signedContent, $secret);
// 3. Timing-safe comparison
if (!hash_equals($expected, $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$event = json_decode($rawBody, true);
error_log("Received {$event['event_topic']}: " . json_encode($event['data']));
http_response_code(200);
echo 'OK';Processing Events
Respond Quickly
Your endpoint must return a 2xx status code within 15 seconds. If the request times out, it counts as a failed delivery and triggers a retry.
The recommended pattern is:
- Verify the signature and timestamp.
- Return
200 OKimmediately. - Process the event asynchronously (e.g., push to a job queue, write to a database, trigger a workflow).
Deduplicate Events
The same event may be delivered more than once due to retries or network issues. Use the event_id field (also in the X-SeloraX-Webhook-Event-Id header) to deduplicate:
// Example: check if we already processed this event
const processed = await db.query(
"SELECT 1 FROM webhook_events_log WHERE event_id = ?",
[event.event_id],
);
if (processed.length > 0) {
return res.status(200).send("Already processed");
}
// Store event_id before processing
await db.query(
"INSERT INTO webhook_events_log (event_id, topic, received_at) VALUES (?, ?, NOW())",
[event.event_id, event.event_topic],
);
// Process the event...Handle Events Idempotently
Design your event handlers so that processing the same event twice produces the same result. For example:
- Good: "Set order status to
confirmed" (idempotent -- running it twice has the same effect) - Bad: "Increment order count by 1" (not idempotent -- running it twice gives the wrong count)
Route by Topic
Use the event_topic field to route events to the appropriate handler:
async function handleWebhookEvent(event) {
switch (event.event_topic) {
case "order.status_changed":
await handleOrderStatusChange(event.data);
break;
case "order.created":
await handleNewOrder(event.data);
break;
case "charge.activated":
await handleChargeActivated(event.data);
break;
default:
console.log(`Unhandled event topic: ${event.event_topic}`);
}
}Best Practices
| Practice | Why |
|---|---|
| Always verify signatures | Prevents unauthorized parties from sending fake events to your endpoint |
| Check timestamp freshness | Prevents replay attacks where an attacker resends a captured delivery |
Use crypto.timingSafeEqual (or language equivalent) | Prevents timing attacks that could leak your signing secret |
| Return 200 quickly, process async | Avoids timeouts and unnecessary retries |
Store event_id for deduplication | The same event may be delivered more than once |
| Handle events idempotently | Retries and race conditions can cause duplicate processing |
| Use HTTPS for your endpoint | Protects the payload (including customer data) in transit |
| Keep your signing secret secure | Treat it like a password. Store in environment variables, not in code. |
| Log deliveries for debugging | Store the raw payload and headers so you can diagnose issues |
Troubleshooting
Signature verification fails
- Make sure you are verifying against the raw request body (bytes), not parsed JSON. Parsing and re-serializing JSON may change whitespace or key ordering.
- Ensure you are signing
{timestamp}.{body}— the timestamp from theX-SeloraX-Timestampheader, a dot, then the raw body. - Confirm you are using the correct signing secret (
whsec_...) for this subscription. - Check that no middleware is modifying the request body before your verification code runs.
Timestamp validation fails
- Ensure your server clock is synchronized (use NTP). A clock skew greater than 5 minutes will cause all deliveries to be rejected.
- If testing locally, make sure your system time is accurate.
Not receiving webhooks
- Verify your subscription is active:
GET /api/apps/v1/webhooks— checkis_activeis1. - Check that your endpoint is publicly accessible. Webhooks cannot be delivered to
local serversin production. - Review the delivery logs in the merchant dashboard for error details.
- If
failure_countis 20 or more, your subscription may have been auto-deactivated. See Retry Policy.
Receiving duplicate events
This is expected behavior. Always deduplicate using event_id. See Retry Policy for details on when retries occur.