SeloraXDEVELOPERS

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
HeaderDescription
X-SeloraX-SignatureHMAC-SHA256 signature of {timestamp}.{body}, prefixed with sha256=. See Verifying the Signature.
X-SeloraX-TimestampUnix timestamp (seconds) of when the delivery was signed. Used for replay attack prevention.
X-SeloraX-Webhook-EventThe event topic (e.g., order.status_changed)
X-SeloraX-Webhook-Event-IdUnique event ID (UUID). Use for deduplication.
X-SeloraX-Idempotency-KeySame as event ID. Can be used for idempotent processing.
X-SeloraX-Delivery-IdUnique delivery ID (UUID). Differs from event ID — each retry gets a new delivery ID.
User-AgentAlways 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", 200

PHP

<?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:

  1. Verify the signature and timestamp.
  2. Return 200 OK immediately.
  3. 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

PracticeWhy
Always verify signaturesPrevents unauthorized parties from sending fake events to your endpoint
Check timestamp freshnessPrevents 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 asyncAvoids timeouts and unnecessary retries
Store event_id for deduplicationThe same event may be delivered more than once
Handle events idempotentlyRetries and race conditions can cause duplicate processing
Use HTTPS for your endpointProtects the payload (including customer data) in transit
Keep your signing secret secureTreat it like a password. Store in environment variables, not in code.
Log deliveries for debuggingStore 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 the X-SeloraX-Timestamp header, 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 — check is_active is 1.
  • Check that your endpoint is publicly accessible. Webhooks cannot be delivered to local servers in production.
  • Review the delivery logs in the merchant dashboard for error details.
  • If failure_count is 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.