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.