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/errorsenvelope.
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:
- Body field
auth— what Sharecare's spec uses. No other field name works. 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;statustrackssticky_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 originalsubmission_idinstead of making a secondnew_prospectcall. 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 markedsticky_failed, a "NOT IN STICKY" Slack alert fires, and the partner gets a502witherror inserting leadso 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/searchfirst. That search path is CallRail-specific: it looks for anINCALLlead, which only exists during a live inbound call. Convoso'scheck_dup=3handles 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_LISTSis a JSON map of partner slug → list id (e.g.{"sharecare":"22575"});CONVOSO_EXTERNAL_LEAD_LIST_IDis 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: falseon 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 badcreated_byreturns6006 No such Userand 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), nohopperparams, and noconsent_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 asubmission_id. Worth adding once confirmed — the lead doc holdsconvosoLeadIdin the meantime. - The CallRail webhook still sends its full param set (including
created_by=ltw); that value is unverified and will hit the same6006when 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.