← Back to Blog
April 4, 2026programming

Building HMAC-Signed Webhooks for Real-Time Trading Signals

By APIndicators

When you deliver trading signals to customer endpoints, you are handing them the ability to move real money. Your webhooks need three properties: authenticity (the customer must prove the payload came from you), reliability (transient failures cannot lose signals), and observability (you need to know which deliveries failed and why).

This post walks through the webhook system we built at APIndicators to push real-time trading signals to customer endpoints. We use HMAC-SHA256 signing, 3 retries with polynomial backoff, delivery log tracking, and 2KB response body truncation. Every piece is there for a reason.

Why HMAC, Not JWT or OAuth

For server-to-server webhook delivery, HMAC is the right tool:

  • JWTs are designed for identity claims passed through untrusted intermediaries. Overkill for signing a POST body.
  • OAuth is for delegated authorization. The customer is not delegating anything; you are just proving you sent the request.
  • HMAC is simple, stateless, fast, and rotation-friendly. You share one secret per webhook endpoint, sign the body, done.

The pattern most webhook producers use (Stripe, GitHub, Shopify) looks like this:

X-Webhook-Signature: sha256=<hex-encoded-hmac-of-body>
X-Webhook-Timestamp: <unix-timestamp>

The timestamp prevents replay attacks. The HMAC proves the body was not modified and was signed by someone holding the secret.

The Server Side: Signing Outgoing Webhooks

Here is the core signing logic, in Python:

import hmac
import hashlib
import time
import json
import httpx

def sign_body(secret: str, body: bytes, timestamp: int) -> str:
    signed_payload = f"{timestamp}.".encode() + body
    digest = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
    return f"sha256={digest}"

def deliver(webhook_url: str, secret: str, payload: dict) -> httpx.Response:
    body = json.dumps(payload, separators=(",", ":")).encode()
    timestamp = int(time.time())
    signature = sign_body(secret, body, timestamp)

    headers = {
        "Content-Type": "application/json",
        "X-Webhook-Signature": signature,
        "X-Webhook-Timestamp": str(timestamp),
        "X-Webhook-Id": payload["id"],
    }

    return httpx.post(webhook_url, content=body, headers=headers, timeout=10.0)

A few things worth noting:

  • Sign timestamp.body, not just body. This binds the signature to a specific moment, preventing replay.
  • Use compact JSON. Canonical serialization matters because the receiver will reserialize to verify. separators=(",", ":") removes whitespace.
  • Include an idempotency ID. Receivers need a way to detect duplicate deliveries when your retry logic fires.

Retry Policy: 3 Attempts, Polynomial Backoff

Exponential backoff is conventional but overkill for webhook retries. We use polynomial backoff (delay grows as attempt^2) because we want quick retries for transient failures and do not want customers waiting 30 seconds for the third attempt.

async def deliver_with_retry(webhook_url, secret, payload, max_attempts=3):
    base_delay_seconds = 2
    for attempt in range(1, max_attempts + 1):
        try:
            response = await deliver_async(webhook_url, secret, payload)
            if 200 <= response.status_code < 300:
                return {"ok": True, "attempt": attempt, "status": response.status_code}
            if 400 <= response.status_code < 500 and response.status_code != 429:
                return {"ok": False, "attempt": attempt, "status": response.status_code, "permanent": True}
        except (httpx.TimeoutException, httpx.ConnectError) as e:
            last_error = str(e)

        if attempt < max_attempts:
            delay = base_delay_seconds * (attempt ** 2)
            await asyncio.sleep(delay)

    return {"ok": False, "attempts": max_attempts, "permanent": False}

Delays with base 2 seconds: 2s, 8s, 18s. Total worst case ~28 seconds. Fast enough that retrying during a transient outage works, slow enough to respect the customer's server during real incidents.

Important: do not retry 4xx responses except 429. A 400 Bad Request means your payload is malformed. Retrying will not help. A 401 Unauthorized means the secret is wrong. Retrying will not help either.

Delivery Log Tracking

Every webhook attempt writes a row to a delivery log. This is table stakes for debuggability:

CREATE TABLE webhook_deliveries (
  id UUID PRIMARY KEY,
  endpoint_id UUID NOT NULL,
  event_id VARCHAR(64) NOT NULL,
  attempt INTEGER NOT NULL,
  status_code INTEGER,
  response_body TEXT,
  request_headers JSONB,
  duration_ms INTEGER,
  error TEXT,
  succeeded_at TIMESTAMPTZ,
  attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_webhook_deliveries_endpoint_time
  ON webhook_deliveries (endpoint_id, attempted_at DESC);

2KB Response Body Truncation

We truncate response bodies to 2KB before storing. Customers have been known to return entire HTML pages or 50MB error dumps when their endpoints misbehave, and those will blow up your storage and logs. 2KB is plenty to diagnose the issue.

MAX_RESPONSE_BODY = 2048

def truncate_body(text: str) -> str:
    if text is None:
        return None
    text = text[:MAX_RESPONSE_BODY]
    if len(text) == MAX_RESPONSE_BODY:
        text += "...[truncated]"
    return text

The Client Side: Verifying Signatures

This is what you want to give your customers. Three implementations:

Python (Flask)

import hmac
import hashlib
import time
from flask import request, abort

WEBHOOK_SECRET = os.environ["APINDICATORS_WEBHOOK_SECRET"]
MAX_AGE_SECONDS = 300

def verify_webhook():
    signature_header = request.headers.get("X-Webhook-Signature", "")
    timestamp = request.headers.get("X-Webhook-Timestamp", "")

    if not signature_header.startswith("sha256="):
        abort(401)

    received = signature_header.split("=", 1)[1]

    if abs(int(time.time()) - int(timestamp)) > MAX_AGE_SECONDS:
        abort(401)

    signed_payload = f"{timestamp}.".encode() + request.get_data()
    expected = hmac.new(WEBHOOK_SECRET.encode(), signed_payload, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(received, expected):
        abort(401)

Node.js (Express)

const crypto = require("crypto");

function verifyWebhook(req, res, next) {
  const signatureHeader = req.headers["x-webhook-signature"] || "";
  const timestamp = req.headers["x-webhook-timestamp"] || "";

  if (!signatureHeader.startsWith("sha256=")) {
    return res.status(401).send("Invalid signature");
  }
  const received = signatureHeader.slice(7);

  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
    return res.status(401).send("Stale timestamp");
  }

  const signedPayload = Buffer.concat([
    Buffer.from(`${timestamp}.`),
    req.rawBody,
  ]);
  const expected = crypto
    .createHmac("sha256", process.env.APINDICATORS_WEBHOOK_SECRET)
    .update(signedPayload)
    .digest("hex");

  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send("Signature mismatch");
  }
  next();
}

Ruby (Rails)

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :verify_signature

  def receive
    head :ok
  end

  private

  def verify_signature
    signature_header = request.headers["X-Webhook-Signature"].to_s
    timestamp = request.headers["X-Webhook-Timestamp"].to_s
    return head(:unauthorized) unless signature_header.start_with?("sha256=")

    received = signature_header.split("=", 2).last
    return head(:unauthorized) if (Time.current.to_i - timestamp.to_i).abs > 300

    signed_payload = "#{timestamp}.#{request.raw_post}"
    expected = OpenSSL::HMAC.hexdigest("SHA256", ENV["APINDICATORS_WEBHOOK_SECRET"], signed_payload)

    head(:unauthorized) unless ActiveSupport::SecurityUtils.secure_compare(received, expected)
  end
end

Notice the constant-time comparison in all three. Never use == on signature strings — it leaks information through timing side channels.

Practical Takeaways

  • Sign timestamp.body with HMAC-SHA256, not just body. Bind signatures to a moment.
  • Polynomial backoff (attempt^2) gives better UX than exponential for the 3-retry case.
  • Store compact delivery logs with 2KB response truncation. You will thank yourself.
  • Use constant-time comparison on signature verification on both sides.
  • Include an idempotency ID so receivers can deduplicate retries safely.

APIndicators ships real-time signal webhooks on every plan. Set them up from your dashboard or see /docs for the full webhook reference. Paid plans include HMAC signing by default.