LeadRails

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 from
  • lead — object present on the event; every field inside it is optional
  • submitted_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_name
  • message — free text up to 20,000 chars
  • service_type, urgency, preferred_contact_method

Optional sub-objects:

  • location — address line, city, state, postal code, country
  • attribution — UTM tags, gclid, fbclid, landing page, referrer
  • consentsms_consent, email_consent, privacy_policy_url
  • custom_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/sdk instead — 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.