LeadRails

Webhooks

When you wire a Generic Webhook destination to your own endpoint, LeadRails signs each delivered request so your receiver can verify it actually came from us. This page is the receiver-side verification contract.

Looking for the other direction — signing the lead events you send to LeadRails? That's the HMAC signing page (the intake surface).

Turn on signing

Open your Generic Webhook destination, expand Advanced, and create a Signing secret. Copy the value once and configure your receiver to verify it. Signing is active only while the destination has at least one active signing secret; without one, deliveries are sent unsigned (the destination URL itself is the only authentication — fine for opaque iPaaS URLs, not for your own endpoint).

Headers on every signed delivery

Header Value
X-LeadRails-Timestamp ISO-8601 UTC, set at signing time.
X-LeadRails-Signature v1=<base64> — the HMAC.
X-LeadRails-Signature-Key-Id Which secret signed it. Handy during rotation.
X-Idempotency-Key Stable per delivery job, so you can dedupe replays.

The signature

The signed value is the timestamp and a hash of the body, joined by a literal dot, HMAC'd with your signing secret:

base       = X-LeadRails-Timestamp + "." + sha256_hex(raw_request_body)
signature  = base64( HMAC-SHA-256(signing_secret, base) )
header     = "X-LeadRails-Signature: v1=" + signature

Hashing the body (rather than signing the raw bytes directly) lets a receiver verify without buffering the whole body, and the v1= prefix versions the scheme so we can evolve it without breaking existing receivers. To verify: recompute the signature over the exact raw bytes you received and compare in constant time.

Replay protection

The timestamp prefix is your replay defence. Reject any request whose X-LeadRails-Timestamp is outside a small clock-skew window (±5 minutes is a sensible default) before trusting the body. For stricter guarantees, record each delivery's X-Idempotency-Key in a short-lived nonce table and reject repeats — that also makes your handler idempotent against the outbox worker re-delivering a job.

Verify it

TypeScript / Node

import { createHash, createHmac, timingSafeEqual } from "node:crypto";

function verifyLeadRailsSignature(rawBody: string, headers: Record<string, string>, secret: string): boolean {
  const ts = headers["x-leadrails-timestamp"];
  const sig = headers["x-leadrails-signature"]; // "v1=<b64>"
  if (!ts || !sig?.startsWith("v1=")) return false;

  // Reject anything older than 5 minutes — defence-in-depth against replay.
  if (Math.abs(Date.now() - Date.parse(ts)) > 5 * 60 * 1000) return false;

  const bodyHash = createHash("sha256").update(rawBody).digest("hex");
  const expected = createHmac("sha256", secret)
    .update(`${ts}.${bodyHash}`)
    .digest("base64");

  const a = Buffer.from(sig.slice(3), "base64");
  const b = Buffer.from(expected, "base64");
  return a.length === b.length && timingSafeEqual(a, b);
}

Python

import base64, hashlib, hmac, time
from datetime import datetime, timezone

def verify_leadrails_signature(raw_body: bytes, headers: dict, secret: str) -> bool:
    ts = headers.get("x-leadrails-timestamp")
    sig = headers.get("x-leadrails-signature")  # "v1=<b64>"
    if not ts or not sig or not sig.startswith("v1="):
        return False

    # Reject anything older than 5 minutes.
    sent = datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
    if abs(time.time() - sent) > 5 * 60:
        return False

    body_hash = hashlib.sha256(raw_body).hexdigest()
    base = f"{ts}.{body_hash}".encode()
    expected = base64.b64encode(hmac.new(secret.encode(), base, hashlib.sha256).digest())
    return hmac.compare_digest(expected, sig[3:].encode())

Go

package leadrails

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"strings"
	"time"
)

func VerifySignature(rawBody []byte, ts, sig, secret string) bool {
	if ts == "" || !strings.HasPrefix(sig, "v1=") {
		return false
	}

	// Reject anything older than 5 minutes.
	sent, err := time.Parse(time.RFC3339, ts)
	if err != nil || time.Since(sent).Abs() > 5*time.Minute {
		return false
	}

	sum := sha256.Sum256(rawBody)
	base := ts + "." + hex.EncodeToString(sum[:])

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(base))
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	return hmac.Equal([]byte(expected), []byte(strings.TrimPrefix(sig, "v1=")))
}

cURL

Recompute the signature from a captured request and compare it to the header by eye. Replace SECRET and the captured values:

SECRET='your_signing_secret'
TS='2026-05-25T14:02:11.000Z'      # the X-LeadRails-Timestamp you received
BODY='{"lead":{"email":"jane@example.com"}}'   # the exact raw body bytes

BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
printf '%s.%s' "$TS" "$BODY_HASH" \
  | openssl dgst -sha256 -hmac "$SECRET" -binary \
  | openssl base64
# Prefix the output with "v1=" and compare to X-LeadRails-Signature.

Rotation

Multiple active secrets are supported for zero-downtime rotation: create a new one, deploy it to your receiver (accepting either secret), then revoke the old one. LeadRails always signs with the most recently created active secret, and X-LeadRails-Signature-Key-Id tells you which one signed any given request.

Troubleshooting

Signature never matches
You're almost certainly hashing a re-serialized body. Sign the exact raw bytes you received — don't parse and re-stringify the JSON first.
Verification passes but late requests slip through
Add the timestamp-skew check above, and dedupe on X-Idempotency-Key.

What's next

  • HMAC signing — the inbound contract for events you send to LeadRails.
  • Quickstart — wire a source → destination → route end to end.
  • Idempotency — the same dedupe discipline on the REST API.