HMAC signing
Wire contract for the intake surface at intake.leadrails.dev.
Use this when you're integrating from a runtime the SDK doesn't cover —
otherwise use @leadrails/sdk, which
implements everything on this page for you.
Endpoint
POST https://intake.leadrails.dev/v1/lead-events
Content-Type: application/json
This is a separate host from the admin REST API
(api.leadrails.dev). The intake surface accepts signed
lead events; the admin surface manages sources, destinations, and
routes. The two never share auth: the admin surface uses bearer keys
(lr_live_…), the intake surface uses per-source HMAC.
What you need from the dashboard
Create a source in the dashboard (Sources), then create a signing key on that source. The dashboard hands you four values; capture them once — the secret is shown once and is recoverable only by rotating the key.
| Value | Shape | Where it goes |
|---|---|---|
| Client ID | cli_01J… | X-LR-Client-Id header |
| Source ID | src_01J… | X-LR-Source-Id header |
| Key ID | key_01J… | X-LR-Key-Id header |
| Signing secret | plaintext, shown once | HMAC key (never sent on the wire) |
Headers on every signed request
| Header | Value |
|---|---|
Content-Type | application/json |
X-LR-Client-Id | Your client ID. |
X-LR-Source-Id | The source whose key signs this request. |
X-LR-Key-Id | The signing key under that source. |
X-LR-Timestamp | ISO-8601 UTC. Must be within 5 minutes of server time. |
X-LR-Nonce | Fresh per request. UUID without dashes is fine. Replayed nonces are rejected. |
X-LR-Idempotency-Key | Stable per logical submission. Retries with the same key + body dedupe. |
X-LR-Signature | v1=<base64(HMAC-SHA-256(secret, baseString))> |
Base string
The HMAC covers a canonical concatenation of seven fields, joined by literal dots. The hash and the method are lowercase. The body hash is the SHA-256 of the exact byte sequence you put on the wire, lowercase hex-encoded.
{timestamp}.{nonce}.{idempotency_key}.{lowercase_method}.{url_path}.{sha256_hex(body)} For a POST /v1/lead-events:
2026-05-24T14:02:11.000Z.<nonce>.<idem>.post./v1/lead-events.<hex_body_hash>
Compute HMAC-SHA-256 over that string using the plaintext signing
secret as the key, then base64-encode the raw 32-byte digest and
prepend v1=. The result goes in X-LR-Signature.
Worked example
Web Crypto only — runs in Node 18+, Bun, Deno, Cloudflare Workers, Vercel Edge, and modern browsers (server-side only — never sign in the browser; the secret would leak).
// LeadRails — HMAC-signed lead submission, Web Crypto, zero deps.
const INTAKE_URL = "https://intake.leadrails.dev/v1/lead-events";
const CLIENT_ID = process.env.LEADRAILS_CLIENT_ID!;
const SOURCE_ID = process.env.LEADRAILS_SOURCE_ID!;
const KEY_ID = process.env.LEADRAILS_KEY_ID!;
const SECRET = process.env.LEADRAILS_SIGNING_SECRET!;
async function sendLead(lead: Record<string, unknown>) {
const timestamp = new Date().toISOString();
const nonce = crypto.randomUUID().replace(/-/g, "");
const idempotencyKey = crypto.randomUUID();
const payload = {
schema_version: "lead_event.v1",
event_type: "lead.submitted",
source: { source_system: "world-flags-feedback" },
lead,
submitted_at: timestamp,
};
const body = JSON.stringify(payload);
// 1. SHA-256 of the body, lowercase hex.
const bodyHash = Array.from(
new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body))),
)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
// 2. Canonical base string. Order, dots, and lowercase matter.
const baseString =
`${timestamp}.${nonce}.${idempotencyKey}.post.${new URL(INTAKE_URL).pathname}.${bodyHash}`;
// 3. HMAC-SHA-256, base64-encoded, prefixed with "v1=".
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = new Uint8Array(
await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(baseString)),
);
const signature = "v1=" + btoa(String.fromCharCode(...sig));
const response = await fetch(INTAKE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-LR-Client-Id": CLIENT_ID,
"X-LR-Source-Id": SOURCE_ID,
"X-LR-Key-Id": KEY_ID,
"X-LR-Timestamp": timestamp,
"X-LR-Nonce": nonce,
"X-LR-Idempotency-Key": idempotencyKey,
"X-LR-Signature": signature,
},
body,
});
if (!response.ok) {
throw new Error(`LeadRails intake refused: ${response.status} ${await response.text()}`);
}
return response.json() as Promise<{ ok: true; event_id: string; status: string }>;
} Payload shape (lead_event.v1)
The body must validate against the v1 schema. Required:
schema_version— literal"lead_event.v1"event_type— literal"lead.submitted"source.source_system— slug naming where the lead came fromlead— object present on the event; every field inside it is optionalsubmitted_at— ISO-8601 timestamp
The schema is structural-only. It does not enforce "must have email or
phone" — that's a per-source / per-destination policy concern.
Practically, you'll want at least one identifying field
(email, phone, or something in custom_fields) so the downstream destination has
something to act on.
Common fields on lead:
full_name,first_name,last_namemessage— free text up to 20,000 charsservice_type,urgency,preferred_contact_method
Optional sub-objects:
location— address line, city, state, postal code, countryattribution— UTM tags, gclid, fbclid, landing page, referrerconsent—sms_consent,email_consent,privacy_policy_urlcustom_fields— arbitrary JSON object forwarded as-is
Optional route selector:
route_slug — slug naming which set of routes on this
source fires. If omitted, the source's default routes fire.
Use this when one source fans out to different routes for different lead
types (e.g. pricing-inquiry vs support-request).
{
"schema_version": "lead_event.v1",
"event_type": "lead.submitted",
"route_slug": "pricing-inquiry",
"source": { "source_system": "wordpress_gravityforms", "site_url": "https://example.com" },
"lead": { "email": "jane@example.com", "full_name": "Jane Doe", "message": "Quote please" },
"submitted_at": "2026-05-24T14:02:11.000Z"
}
The schema is .passthrough() at every level — extra
fields are preserved, not rejected. Producers can carry arbitrary
provider-specific context without breaking the contract.
Errors
| Status | errorCode | What it means | Fix |
|---|---|---|---|
| 401 | auth_failed | HMAC didn't validate. | Most common cause: trailing newline in the secret file, or the body you signed isn't byte-for-byte what went on the wire. Re-print the base string client-side and compare lengths. |
| 401 | stale_timestamp | Timestamp is more than 5 minutes off server time. | Sync your clock (NTP). Generate the timestamp at call time, not at boot. |
| 401 | replayed_nonce | This nonce was used recently. | Generate a fresh nonce per request. A UUID without dashes is the safe default. |
| 409 | idempotency_key_collision | Same idempotency key was submitted with a different body. | Use one stable key per logical submission. Don't reuse across distinct submissions. |
| 422 | schema_validation_failed | Body didn't match lead_event.v1. | The response body names the offending path. Usually a typo in schema_version or a missing lead.email/lead.phone. |
| 404 | route_not_found | The route_slug doesn't match any routes on this source. | Check the slug against the source's Routes tab in the dashboard. |
| 409 | route_paused | The targeted routes are paused. | Re-enable the routes, or omit route_slug to fall back to the default. |
| 503 | transient_storage_error | Intake is temporarily degraded. | Retry with exponential backoff. Reuse the same idempotency key on the retry. |
This table covers the most common codes. The intake worker also emits invalid_json (400), missing_idempotency_key (400),
method_not_allowed (405), unsupported_content_type (415),
client_archived (410), and default_route_missing (500).
The auth_failed envelope carries a reason field
that names the specific signature problem (bad_signature, stale_timestamp, replayed_nonce, unsupported_signature_version, bad_timestamp_format, unknown_source, unknown_key, secret_decrypt_failed, or missing_header:<name>).
@leadrails/sdk flattens the reason into err.errorCode for ergonomic branching. If the response has
no error field at all (e.g., a Cloudflare 5xx HTML page),
the SDK falls back to http_<status> — so
defensive code should treat unknown errorCode values as
transient.
Reference implementations in other languages
Coming soon: Python (hmac + hashlib +
requests), PHP (hash_hmac + cURL), Ruby
(OpenSSL::HMAC + Net::HTTP), Go (crypto/hmac
+ net/http). If you need one before it's published,
email us —
we'll send a working example.
What's next
- Use
@leadrails/sdkinstead — the SDK implements every header and the base string for you, and is the supported path. - Quickstart — create a source + destination + route, then fire a signed event.
- Error catalog — the admin-surface counterparts of the codes above, with stable type URLs you can pin against.