POST /api/leads/intake
Push a new lead into a tenant. The only public write endpoint in v1.
This is the endpoint HREF developers wire client lead forms into. Every successful POST creates a lead, fires a realtime broadcast to the tenant's CRM, and (eventually) emails every member of the tenant.
Endpoint
POST https://<tenant>.hrefcreative.co/api/leads/intake
<tenant> is the slug assigned at provisioning (e.g. acme). In local development, replace the host with <tenant>.localtest.me:3001.
Headers
| Header | Required | Notes |
|---|---|---|
Content-Type | yes | Must be application/json |
X-API-Key | yes | The tenant's API key, in the form hcrm_<43 chars> |
Request body
A JSON object. Every field is optional individually, but at least one of email or phone must be present. Unknown top-level fields are stripped.
| Field | Type | Notes |
|---|---|---|
first_name, last_name | string | trimmed, max 200 chars each |
email | string | valid email format, lowercased and trimmed, max 320 chars |
phone | string | trimmed, max 50 chars (no format enforcement) |
street, city, state, zip | string | trimmed, max varies |
source | string | free text, max 200 chars; surfaces in the inbox row, kanban card, and notification email |
utm_source ... utm_content | string | full UTM set, max 200 each |
metadata | object | open-ended JSON object; arbitrary extras (loan amount, case type, anything your form sends) are preserved here |
The metadata blob is the v1 escape hatch for industry-specific fields: a divorce attorney's case_type, a mortgage broker's loan_amount, etc. Send anything; it's stored as-is and surfaces in the lead detail view as a key/value list.
Response
201 Created
{
"lead_id": "79732b65-ec7d-4e9e-91f7-90bc42a0f763",
"created_at": "2026-05-10T16:42:11.834Z"
}
400 Bad Request
The body did not parse as JSON, or it failed validation.
{
"error": "invalid_request",
"message": "Body did not pass validation.",
"details": [
{ "path": "email", "message": "At least one of email or phone is required" }
]
}
401 Unauthorized
Missing or invalid X-API-Key.
{ "error": "invalid_api_key", "message": "API key is invalid for this tenant." }
404 Not Found
The subdomain does not match a known tenant.
{ "error": "tenant_not_found", "message": "No tenant matches this subdomain." }
5xx Internal Error
{ "error": "internal_error", "message": "Failed to create lead." }
Safe to retry: leads are not deduplicated in v1, but the bigger concern is Resend retries on the email side. Idempotency keys land in a future phase if double-submits become a real problem.
Examples
curl
curl -X POST https://acme.hrefcreative.co/api/leads/intake \
-H "Content-Type: application/json" \
-H "X-API-Key: hcrm_AJoMq2B4SvJDvshNJqruPrZOIudmVh1r1QkwNuYJU4M" \
-d '{
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com",
"phone": "+15551234567",
"source": "website-contact-form",
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "spring-2026",
"metadata": {
"case_type": "divorce",
"estimated_assets": 450000
}
}'
Node 22+ (fetch)
const res = await fetch("https://acme.hrefcreative.co/api/leads/intake", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.ACME_CRM_KEY!,
},
body: JSON.stringify({
first_name: "Jane",
email: "jane@example.com",
source: "website-contact-form",
metadata: { case_type: "divorce" },
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(`intake failed: ${err.error} ${err.message}`);
}
const { lead_id } = await res.json();
Python (requests)
import os, requests
resp = requests.post(
"https://acme.hrefcreative.co/api/leads/intake",
headers={
"Content-Type": "application/json",
"X-API-Key": os.environ["ACME_CRM_KEY"],
},
json={
"first_name": "Jane",
"email": "jane@example.com",
"source": "website-contact-form",
"metadata": {"case_type": "divorce"},
},
timeout=10,
)
resp.raise_for_status()
print(resp.json()["lead_id"])
What happens server-side
- The proxy resolves the tenant from the host header and stamps
x-tenant-slugon the request. - The route handler resolves the tenant (cached for 60 seconds) and returns 404 if it does not exist.
- The provided
X-API-Keyis hashed with SHA-256 and compared in constant time against the storedtenants.api_key_hash. Mismatches return 401. - The body is parsed and validated with Zod. Failures return 400 with field-level details.
- The shared
createLead()helper:- Looks up the tenant's first
stage_type='active'pipeline stage and lands the lead there. - Inserts the
leadsrow via service role withstatus='new'. - Inserts a
lead_activities.createdrow. - Fires-and-forgets a Resend email to every active member of the tenant.
- Supabase Realtime auto-broadcasts the insert on the
tenant:<id>channel.
- Looks up the tenant's first
- The handler returns
201with the newlead_idandcreated_at.
The same createLead() helper is used by the manual-entry form in the CRM UI, so the API and UI cannot drift.
What's deliberately not in v1
- Rate limiting. HREF controls every form posting in. If a runaway form floods a tenant, we'll see it and add per-key throttling at that point.
- Idempotency keys. Skipped until double-submits become a real problem.
- Outbound webhooks. No "lead created → POST anywhere" feature yet.
- Bulk insert. One lead per request.