docs
status: livinglast-verified: 2026-09-29audience: engineers, ops, externalvisibility: public

External lead intake API

Bottom line. Sharecare and similar partners POST a qualified lead to one authenticated URL. We store it in Firestore, create a Sticky prospect so the call center can work it, insert it into the Convoso dialer so agents don't have to retype it, notify Slack, dedupe reposts for 24 hours, and answer in Sharecare's own success / submission_id / errors envelope.

Base URL: https://admin.fourammedia.com

Route: POST /api/external/external-lead-generation

Content types: application/x-www-form-urlencoded (what Sharecare sends) or JSON.

Auth#

Partners authenticate with an external API key (pk_live_…) that has the lead-intake permission and repo access to oricle-hearing-aid (or whichever repo they submit for). Mint it on the API Keys page in 4am-admin — that key string is the shared secret we hand the partner.

Two variants are accepted:

  1. Body field auth — what Sharecare's spec uses. No other field name works.
  2. Authorization: Bearer pk_live_… header — for partners that prefer it.

The secret is never read from the query string — URLs land in proxy and access logs. Every other field may come from the query string of the URL we hand the partner, so fields their form doesn't send (notably campaign_id) can be baked into that URL. Body values win when a field appears in both.

Bad or missing auth returns {"success":false,"errors":["Access Denied: Invalid auth parameter"]} and nothing is written. The same error is returned when the key lacks the lead-intake permission or the requested repo, so a partner can't probe which keys exist.

Accepted fields#

Snake case and camel case both work; snake case is the documented form.

Field Required Notes
auth when no Authorization header Shared secret (the pk_live_… key), body only
first_name yes Lead first name
last_name yes Lead last name
email yes Must be a valid email shape
phone yes Digits are normalized; must be a valid US 10/11-digit number
campaign_id yes Sticky campaign the prospect is created under. No default — may be supplied in the endpoint URL instead of the body
repo or brand no Defaults to oricle-hearing-aid
source no Partner/source label; passed to Sticky as c1
campaign no The partner's own campaign label; passed to Sticky as c2. Not the Sticky campaign
sub_id no Passed to Sticky as sid
afid no Passed to Sticky as afid
notes no Freeform notes, passed to Sticky
any other scalar field no Preserved on the stored lead's raw payload

Response shape#

Matches the partner spec exactly — two keys on success, two on failure.

Accepted lead (and any repost of one already in Sticky, which returns the original id):

{ "success": true, "submission_id": "SHARECARE_9f3c1a0b42e94f0e8c7d1a2b3c4d5e6f" }

Rejected lead — errors lists every problem found, not just the first:

{ "success": false, "errors": ["first_name is required", "phone format is invalid"] }

Error strings: first_name is required, last_name is required, email is required, email format is invalid, phone is required, phone format is invalid, campaign_id is required, error inserting lead, Access Denied: Invalid auth parameter.

Status codes#

HTTP status Meaning
200 Lead accepted and a Sticky prospect exists for it
400 Payload failed validation (errors lists each failure)
401 / 403 Missing/invalid key, or key lacks permission or repo access
502 Lead stored but Sticky definitively rejected it — partner should retry
500 Unexpected server failure

All responses send Cache-Control: no-store.

Example curl#

curl -X POST "https://admin.fourammedia.com/api/external/external-lead-generation?campaign_id=123&source=sharecare" \
  -d "auth=pk_live_YOUR_KEY_HERE" \
  -d "first_name=Jane" \
  -d "last_name=Doe" \
  -d "[email protected]" \
  -d "phone=2125551212"

Ready-to-paste partner template#

Sharecare's own spec posts only first_name, last_name, email, phone and auth. Rather than ask them to add fields, put the routing fields in the URL they post to and let them send their standard body. The secret stays in the body — never the query string, which lands in access logs.

POST https://admin.fourammedia.com/api/external/external-lead-generation?campaign_id=<STICKY_CAMPAIGN_ID>&source=sharecare
Content-Type: application/x-www-form-urlencoded

auth=<SHARED_SECRET>&first_name=&last_name=&email=&phone=

Partners who can send extra fields may put campaign_id (and repo, sub_id, afid, campaign, notes) in the body instead; body wins on collision.

Use a placeholder in docs and tickets — send the real key over a secure channel.

Operational behavior#

  • Leads are written to Firestore collection externalLeads; status tracks sticky_pending → sent_to_sticky / sticky_failed / sticky_unknown / sticky_skipped.
  • The lead doc is created as sticky_pending, which is a delivery claim: a simultaneous repost sees the live claim and returns the original submission_id instead of making a second new_prospect call. The claim expires after 2 minutes so a killed invocation doesn't strand the lead.
  • The Sticky call carries a 15s deadline, well inside the route's 60s maxDuration, so an outcome is always recorded before the platform can kill the invocation.
  • Sticky prospect creation is required for a success response. Sticky v1 answers HTTP 200 even when it rejects a prospect, so the route checks response_code (100 = accepted), error_found, and the returned prospect id before calling it a success.
  • Confirmed rejection (response_code, error_found, HTTP 4xx, missing config): no prospect exists, so the lead is marked sticky_failed, a "NOT IN STICKY" Slack alert fires, and the partner gets a 502 with error inserting lead so their retry can succeed.
  • Ambiguous failure (timeout, HTTP 5xx, unparseable body, no prospect id): Sticky may already hold a prospect, so the partner is not told to retry — that would risk a second prospect for the same person. The lead is marked sticky_unknown, the partner gets a success response, and a "STICKY OUTCOME UNKNOWN" Slack alert asks a human to check Sticky.
  • If the prospect is created but recording it on the lead doc fails, the request still succeeds (the prospect exists — a retry would duplicate it) and a "PROSPECT NOT RECORDED" Slack alert carries the prospect id for reconciliation.
  • Reposts from the same partner with the same email + phone inside 24 hours return the original submission_id. Sticky is only re-attempted when the first attempt is known to have created nothing: a confirmed rejection (sticky_failed) or an expired claim.
  • Slack failures never fail the HTTP response; confirmed Sticky rejections do.

Convoso dialer delivery#

Once the Sticky prospect exists, the lead is inserted into the partner's Convoso list so agents stop copy/pasting from Slack.

  • Insert only — no /leads/search first. That search path is CallRail-specific: it looks for an INCALL lead, which only exists during a live inbound call. Convoso's check_dup=3 handles a repost that reaches it twice.
  • The list is configured per partner, never hardcoded, so onboarding the next partner is an env change. CONVOSO_EXTERNAL_LEAD_LISTS is a JSON map of partner slug → list id (e.g. {"sharecare":"22575"}); CONVOSO_EXTERNAL_LEAD_LIST_ID is the fallback for all partners.
  • A Convoso failure never changes the partner's response. Telling them to retry would create a second Sticky prospect. The outcome is recorded on the lead doc (convosoStatus, convosoLeadId, convosoListId, convosoError) and the Slack post is retitled "External Lead — NOT IN DIALER" with an explicit "enter this lead in the dialer manually" line.
  • Convoso answers HTTP 200 with success: false on a rejected insert, so the route checks the body, not just the status. The call carries a 15s deadline.
  • Slack posts fire either way — it stays the audit trail and the fallback.
  • The request matches the URL Convoso gave us for this list, and nothing more: auth_token, list_id, phone_number, first_name, last_name, address1, city, state, postal_code, email. Convoso rejects an unrecognized value outright rather than ignoring the field — a bad created_by returns 6006 No such User and the whole insert fails — so no param is sent unless it's confirmed against the list.
  • That means no check_dup, so Convoso's default duplicate handling applies (our own 24h dedupe still runs first), no hopper params, and no consent_date. Add them back once someone confirms the values with Convoso.
  • No external_id, so a Convoso lead can't currently be traced back to a submission_id. Worth adding once confirmed — the lead doc holds convosoLeadId in the meantime.
  • The CallRail webhook still sends its full param set (including created_by=ltw); that value is unverified and will hit the same 6006 when that path goes live.
  • Mixpanel: external-lead-convoso-lead-added-vercel, carrying the status, list id and Convoso lead id.

Shared client: apps/4am-admin/src/_utils/convoso.ts, also used by the CallRail webhook.

Env vars#

Env var Required Purpose
STICKY_API_USERNAME yes Sticky basic-auth username
STICKY_API_PASSWORD yes Sticky basic-auth password
SLACK_EXTERNAL_LEADS_CHANNEL recommended Slack channel ID for lead notifications
SLACK_ORDER_CHANNEL_ID fallback Used if SLACK_EXTERNAL_LEADS_CHANNEL is unset
SLACK_TOKEN_SOURCE_REPO often Lets 4am-admin borrow a working funnel repo's Slack token secret
EXTERNAL_LEAD_STICKY_ENABLED no Set to "false" only to deliberately stop Sticky delivery; leads are then accepted but no one calls them
CONVOSO_AUTH_TOKEN for dialer delivery Convoso API token (shared with the CallRail webhook)
CONVOSO_EXTERNAL_LEAD_LISTS recommended JSON map of partner slug → Convoso list id, e.g. {"sharecare":"22575"}
CONVOSO_EXTERNAL_LEAD_LIST_ID fallback Single list id used when the partner isn't in the map

Without a token or a resolvable list, delivery is skipped (convosoStatus: "skipped") and Slack says the lead needs manual dialer entry.