Skip to main content
    Security Guide

    Webhook signature verification

    Validate every incoming webhook with HMAC-SHA256 before processing. This guide is the single canonical reference for securing your webhook endpoint.

    HMAC-SHA256Industry-standard signing
    Constant-TimeTiming-attack resistant
    5 LanguagesNode · Python · Go · PHP · Ruby

    How It Works

    Every outgoing webhook follows a four-step signing process. Your server mirrors steps 1–2 and compares the result.

    Step 1

    Serialize Payload

    ArosaPay serializes the event payload as canonical JSON.

    {"event":"funds.secured","data":{...}}
    Step 2

    Compute HMAC-SHA256

    The JSON body is signed using your webhook secret key.

    HMAC-SHA256(secret, body) → hex digest
    Step 3

    Send with Signature Header

    The hex digest is attached to the request.

    X-Webhook-Signature: a1b2c3d4e5f6...
    Step 4

    Verify on Your Server

    Recompute the hash and compare using constant-time comparison.

    timingSafeEqual(expected, received)

    Implementation Examples

    Complete, copy-paste-ready webhook handlers with signature verification, replay protection, and idempotency built in.

    Node.js webhook handler
    const crypto = require("crypto");
    const express = require("express");
    const app = express();
    
    // IMPORTANT: Use raw body for signature verification
    app.use("/webhooks", express.raw({ type: "application/json" }));
    
    const WEBHOOK_SECRET = process.env.AROSAPAY_WEBHOOK_SECRET;
    
    function verifySignature(rawBody, signature) {
      const expected = crypto
        .createHmac("sha256", WEBHOOK_SECRET)
        .update(rawBody)
        .digest("hex");
      return crypto.timingSafeEqual(
        Buffer.from(signature, "utf8"),
        Buffer.from(expected, "utf8")
      );
    }
    
    app.post("/webhooks/arosapay", (req, res) => {
      const signature = req.headers["x-webhook-signature"];
      if (!signature || !verifySignature(req.body, signature)) {
        return res.status(401).json({ error: "Invalid signature" });
      }
    
      const event = JSON.parse(req.body);
    
      // Replay protection: reject events older than 5 minutes
      const age = Date.now() - new Date(event.created_at).getTime();
      if (age > 5 * 60 * 1000) {
        return res.status(400).json({ error: "Event too old" });
      }
    
      // Idempotency: skip already-processed events
      if (alreadyProcessed(event.id)) {
        return res.json({ received: true });
      }
    
      switch (event.event) {
        case "funds.secured":
          confirmOrder(event.data.transaction_id);
          break;
        case "payout.sent":
          recordPayout(event.data);
          break;
      }
    
      markProcessed(event.id);
      res.json({ received: true });
    });

    Security Best Practices

    Signature verification alone isn't enough. Follow these practices to build a production-hardened webhook endpoint.

    Constant-Time Comparison

    Never use === or == to compare signatures. Standard string comparison leaks timing information that attackers can exploit to forge valid signatures byte-by-byte.

    Tip: Use crypto.timingSafeEqual (Node), hmac.compare_digest (Python), subtle.ConstantTimeCompare (Go), hash_equals (PHP), or secure_compare (Ruby).

    Replay Protection

    Check the created_at timestamp in every event. Reject any event older than 5 minutes to prevent attackers from replaying captured webhooks.

    Tip: Compare against your server's current UTC time. Ensure your server clock is NTP-synced.

    Idempotency

    Store processed event IDs and skip duplicates. ArosaPay retries failed deliveries, so your endpoint may receive the same event multiple times.

    Tip: Use a database table or Redis set keyed by event.id. Check before processing, insert after.

    Raw Body Parsing

    Always compute the HMAC over the raw request bytes, not a parsed-then-re-serialized object. JSON re-serialization can change whitespace or key order.

    Tip: In Express, use express.raw(). In Flask, use request.data. In PHP, use php://input.

    Secret Rotation

    Rotate your webhook secret periodically. During the transition window, accept signatures from both the old and new secrets to avoid dropped events.

    Tip: Generate a new secret in your dashboard, update your server to accept both, then deactivate the old one after 24 hours.

    Debugging & Testing

    Tools and techniques to verify your implementation locally before going live.

    Generate a Test Signature

    openssl CLI
    # Generate a test signature for a sample payload
    echo -n '{"event":"funds.secured","data":{"transaction_id":"txn_test123"}}' \
      | openssl dgst -sha256 -hmac "whsec_your_test_secret" -hex
    
    # Output: a1b2c3d4e5f6...  (your expected signature)

    Test Your Endpoint

    cURL
    # Test your webhook endpoint with a signed payload
    PAYLOAD='{"id":"evt_test_001","event":"funds.secured","created_at":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","data":{"transaction_id":"txn_test123","reference":"AP-TEST-0001","amount":5000,"currency":"KES","funding_source":"mpesa"}}'
    
    SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "whsec_your_test_secret" -hex | awk '{print $2}')
    
    curl -X POST https://your-domain.com/webhooks/arosapay \
      -H "Content-Type: application/json" \
      -H "X-Webhook-Signature: $SIGNATURE" \
      -d "$PAYLOAD"

    Common Mistakes

    Re-serialized body

    Parsing the JSON body and re-serializing it before computing the HMAC. Whitespace or key order changes will produce a different hash.

    Wrong header name

    Using "X-Arosapay-Signature" instead of "X-Webhook-Signature". Check the exact header name in your framework's request object.

    Encoding mismatch

    Computing the HMAC as base64 instead of hex, or vice versa. ArosaPay sends the hex-encoded digest.

    Missing raw body middleware

    Express.js parses JSON by default. You need express.raw() on your webhook route to get the original bytes.

    Retry Policy

    Failed deliveries are retried up to 5 times with exponential backoff. After all attempts are exhausted, the endpoint is flagged and an admin alert is generated.

    AttemptDelayCumulative
    11 minute1m
    25 minutes6m
    330 minutes36m
    42 hours2h 36m
    524 hours~26h 36m