Skip to main content

Overview

Every webhook sent by StableStack is signed with HMAC-SHA256 using your endpoint’s unique signing secret. Verifying signatures ensures that:
  • The request originated from StableStack
  • The payload has not been tampered with in transit
  • The request is not a replay of an older event

The Signature Header

The signature field is included in every webhook payload:
"signature": "t=1778538982206,s=a43187f66dff73d9681a4ef43c5c93349d95fc10b6dc0482ec65e39b59564a83"
It contains two comma-separated components:
ComponentDescription
tUnix timestamp in milliseconds when the event was created
sHMAC-SHA256 hex digest of the signed message

How Signatures Are Generated

StableStack generates signatures as follows:
  1. Build the payload object (without the signature field):
    {
      "id": "evt_...",
      "timestamp": 1778538982206,
      "event_type": "wallet.transaction.inbound",
      "data": { ... }
    }
    
  2. Serialize it to a JSON string (payloadString = JSON.stringify(payload))
  3. Build the signed message:
    message = "${timestamp}.${payloadString}"
    
  4. Compute HMAC-SHA256(message, signingSecret) and hex-encode the result
  5. Attach to the payload as:
    "signature": "t=${timestamp},s=${hexDigest}"
    

Verifying Signatures

Node.js

const crypto = require('crypto');

function verifyWebhookSignature(payload, signingSecret) {
  const { signature, ...payloadWithoutSignature } = payload;

  // 1. Parse the signature field
  const parts = signature.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const receivedSig = parts.find(p => p.startsWith('s=')).slice(2);

  // 2. Reject stale events (replay protection)
  const now = Date.now();
  const eventTime = parseInt(timestamp, 10);
  const FIVE_MINUTES = 5 * 60 * 1000;

  if (Math.abs(now - eventTime) > FIVE_MINUTES) {
    throw new Error('Webhook timestamp too old — possible replay attack');
  }

  // 3. Rebuild the signed message
  const payloadString = JSON.stringify(payloadWithoutSignature);
  const message = `${timestamp}.${payloadString}`;

  // 4. Compute expected signature
  const expectedSig = crypto
    .createHmac('sha256', signingSecret)
    .update(message)
    .digest('hex');

  // 5. Compare using constant-time comparison
  const expectedBuf = Buffer.from(expectedSig, 'hex');
  const receivedBuf = Buffer.from(receivedSig, 'hex');

  if (
    expectedBuf.length !== receivedBuf.length ||
    !crypto.timingSafeEqual(expectedBuf, receivedBuf)
  ) {
    throw new Error('Invalid webhook signature');
  }

  return true;
}
Express.js example:
app.post('/webhook', express.json(), (req, res) => {
  try {
    verifyWebhookSignature(req.body, process.env.SIGNING_SECRET);
  } catch (err) {
    return res.status(401).json({ error: err.message });
  }

  const { id, event_type, data } = req.body;

  // Respond immediately, process asynchronously
  res.status(200).json({ received: true });

  processEvent(id, event_type, data).catch(console.error);
});

Python

import hmac
import hashlib
import json
import time

def verify_webhook_signature(payload: dict, signing_secret: str) -> bool:
    signature = payload.get("signature", "")
    payload_without_sig = {k: v for k, v in payload.items() if k != "signature"}

    # Parse t= and s= from signature string
    parts = dict(part.split("=", 1) for part in signature.split(","))
    timestamp = parts.get("t", "")
    received_sig = parts.get("s", "")

    # Replay protection: reject if older than 5 minutes
    now_ms = int(time.time() * 1000)
    event_time = int(timestamp)
    if abs(now_ms - event_time) > 5 * 60 * 1000:
        raise ValueError("Webhook timestamp too old — possible replay attack")

    # Rebuild signed message
    payload_string = json.dumps(payload_without_sig, separators=(",", ":"))
    message = f"{timestamp}.{payload_string}"

    # Compute and compare
    expected_sig = hmac.new(
        signing_secret.encode(),
        message.encode(),
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected_sig, received_sig):
        raise ValueError("Invalid webhook signature")

    return True

Replay Attack Prevention

The t timestamp in the signature allows you to reject events that are replayed after a delay. StableStack recommends rejecting any event older than 5 minutes.
const FIVE_MINUTES = 5 * 60 * 1000;
const eventTime = parseInt(timestamp, 10);

if (Math.abs(Date.now() - eventTime) > FIVE_MINUTES) {
  return res.status(401).json({ error: 'Stale webhook — rejected' });
}
If your server’s clock is significantly out of sync, legitimate events may be rejected. Ensure your server uses NTP time synchronisation.

Best Practices

Never hardcode your signing_secret in source code or expose it client-side. Store it as an environment variable and rotate it immediately if compromised.
SIGNING_SECRET=your_secret_here
Use crypto.timingSafeEqual (Node.js) or hmac.compare_digest (Python) when comparing signatures. Standard string equality (===) is vulnerable to timing attacks.
Return a 200 response as soon as signature verification passes. Process the event asynchronously to avoid timeouts that could trigger unnecessary retries.
res.status(200).json({ received: true });
processEvent(event).catch(console.error);
Track signature failures to detect misconfiguration or attack attempts:
} catch (err) {
  logger.warn('Webhook signature verification failed', {
    error: err.message,
    event_id: req.body?.id,
  });
  return res.status(401).json({ error: 'Unauthorized' });
}

Testing Signature Verification

You can test your verification logic using the example payload and the signing secret from your dashboard:
const testPayload = {
  id: "evt_a0b8f4cc-95c4-4c74-9b18-050813546eb5",
  timestamp: 1778538982206,
  event_type: "wallet.transaction.inbound",
  signature: "t=1778538982206,s=a43187f66dff73d9681a4ef43c5c93349d95fc10b6dc0482ec65e39b59564a83",
  data: {
    id: "dd1aebfd-acec-4367-a8dd-bdecea396753",
    amount: "20.00000000",
    status: "COMPLETED",
    // ... rest of the payload
  }
};

// Should return true with the correct signing secret
verifyWebhookSignature(testPayload, process.env.SIGNING_SECRET);
Use the Dashboard to send test events to your endpoint and inspect delivery logs.

Rotating Your Signing Secret

If your signing secret is compromised:
  1. Go to Dashboard → Settings → Webhook
  2. Click Signing Secret
  3. Update your environment variable with the new secret
  4. The old secret is invalidated immediately
Rotating your secret will cause all in-flight webhooks signed with the old secret to fail verification. Update your application before rotating in production.