Lead intake
The default path is still yours: you POST the leads and CogniLead verifies, de-dupes, suppresses, schedules, and sends them. A second, admin-triggered path can source leads from MarketPrior directly into a tenant, but only where a documented legal basis for cold-contacting exists.
On this page
Customer-supplied leads: POST /api/v1/leads
POST /api/v1/leads has two modes, keyed off whether the body includes a campaign_id. Without one, the call is registration-only: the lead is gated, persisted, and returned with a 201 — nothing is sent. With a campaign_id, the route resolves the campaign first (a bad id 404s before any lead row is written, so a typo never leaves an orphan lead behind), then continues straight into runLeadPersisted(): personalize each campaign step, then dispatch — the same pipeline described in §2. A pipeline failure past that point does not erase the lead that was already durably registered; it is reported inline on the response as a pipeline object instead of a 500.
POST /api/v1/leads
Authorization: Bearer pk_live_...
{
"company_domain": "acme.com",
"company_name": "Acme Corp",
"target_product": "cognilead",
"best_contact_email": "cto@acme.com",
"jurisdiction": "US",
"technical_hook_text": "...",
"campaign_id": "camp_123" // optional — omit to just register the lead
}
201 Created
{ "id": "lead_...", "verification_verdict": "valid", "pipeline": { "stage": "dispatch", "proceed": true, ... } }
409 Conflict // the intersect gate denied the lead
{ "error": "suppressed" | "recent_pitch" | "no_contact_email" | "jurisdiction_unknown" | "verification_failed" }The intersect gate
intersect() (lib/pipeline/stages/intersect.ts) is the one gate every lead passes through — customer-supplied or sourced, no exceptions — before it can be scheduled to send. Checks run in a deliberate order, cheapest and most synchronous first, so an obviously-bad lead never pays for an expensive check:
- no_contact_email — reject synchronously if best_contact_email is empty.
- jurisdiction_unknown — reject synchronously if jurisdiction is missing or shorter than 2 characters.
- suppressed — one DB lookup against the tenant's suppression list (see §4); a hit rejects immediately, before spending anything on verification.
- verification_failed — a cache-hit costs one DB query; a cache-miss costs roughly $0.004 and 200-600ms calling out to the email verifier (NeverBounce-style). 'invalid' and 'disposable' verdicts are always rejected. 'catchall' and 'unknown' verdicts pass by default — they often deliver fine, especially for company domains behind Microsoft 365 — but can each be turned into a hard reject via VERIFY_REJECT_CATCHALL=true / VERIFY_REJECT_UNKNOWN=true.
- recent_pitch — reject if the same company_domain × target_product pairing was sent to inside the last 90 days (RECENT_PITCH_WINDOW_DAYS), so the same company never gets pitched the same product twice in one quarter.
A pass through the gate returns { proceed: true, verification_verdict }; the verdict and the verifier's provider + checked-at timestamp are persisted on the lead row (verification_verdict, verification_provider, verification_checked_at) so the dashboard's lead drawer can show the actual provenance rather than discarding it.
MarketPrior sourcing (admin-triggered)
Beyond the customer-supplied default, CogniLead has a second, admin-only lead-population path: sourceLeadsForTenant() pulls candidate companies (or, in a separate mode, retail stores) from the MarketPrior candidates API, converts each into a lead shape, and inserts it directly into the requesting tenant — there is no shared cross-tenant pool a lead is later "assigned" out of. The HTTP entry point is POST /api/v1/internal/marketprior/source-pass, which requires admin role and takes tenant_id, target_product, and country in the body.
A sourced candidate is not exempt from anything a customer-supplied lead goes through: it first clears a jurisdiction-specific legal-basis gate (lib/pipeline/stages/legal-basis-gate.ts), then the same suppression/verification/dedup logic inside the sourcing stage, and finally the exact same intersect() gate described above. The legal-basis table is the single encoding of which jurisdictions currently permit cold-contacting a sourced (not customer-relationship) lead at all:
- can_spam_optout — United States: no prior consent required (CAN-SPAM opt-out model).
- pecr_corporate_or_legitimate_interest — United Kingdom: corporate-subscriber exemption or GDPR legitimate interest.
- gdpr_legitimate_interest — most of the EU/EEA (excluding Germany and Poland, which get a stricter row) plus Norway: GDPR Art. 6(1)(f) legitimate interest, requiring a documented legitimate-interest assessment.
- factual_business_connection — Switzerland, Israel: non-GDPR regimes with their own "genuine/role-relevant business connection" standard.
- implied_consent_published_address — Canada, Australia, New Zealand: implied consent inferred from a conspicuously published, role-relevant business address.
- business_contact_exempt — Singapore, Hong Kong: business contact information is carved out of "personal data" entirely.
- opt_in_required — Germany, Poland, Japan, South Korea, UAE: blocked until a real opt-in flow exists.
- unsupported — any jurisdiction not in the table, including a harder, deliberate block for China rather than an unresearched gap.
Listing and the export queue
GET /api/v1/leads returns the tenant's leads, newest first and cursor-paginated. Passing ?unexported=true switches the source to the export queue — leads whose exported_to column is still null — which is the queue a downstream puller (e.g. a reply-handling product operating on the same lead) reads from rather than being pushed to. Adding &warm=true (only meaningful together with unexported=true) further narrows that queue to leads that already have at least one recorded reply.