Quick start
Generate a key in the Gelee app under Settings, Integrations, API Keys. Pick read_write if you plan to push anything. Then check it works:
curl https://www.gelee.ai/api/v1/me \
-H "Authorization: Bearer gk_xxxxxxxxxxxxxxxx"
{ "data": { "org_id": "0f2c…", "scope": "read_write", "api_version": "v1" } }Use www.gelee.ai, not gelee.ai
The bare domain redirects, and most HTTP clients drop the Authorization header when they follow a redirect to a different host. Your key would be silently lost.
AI assistant (MCP)
The same assistant that lives in the corner of the Gelee app, inside the tools you already use. Connect once with your Gelee login and run your workspace by talking: build and edit campaigns, add leads, check and write messaging, tune the AI SDR, manage seats and teammates, approve waiting reply drafts. It reads your workspace and proposes changes; nothing is saved or sent until you say yes.
Serverweb.gelee.ai/api/v1/mcp
TransportStreamable HTTP
AuthSign in with Gelee (OAuth) or Bearer key
ClientHow to connect
Claude (web or desktop)Settings, Connectors, Add custom connector. Name it Gelee, paste the server address, click Connect and sign in with your Gelee email and password. Pick the workspace and whether it may make changes.
ChatGPTSettings, Connectors, Create. Paste the server address, choose OAuth, sign in with your Gelee login.
Claude CodeCreate a read and write key under Settings, Integrations, API in the app, then run the command below.
Cursor and scriptsStreamable HTTP with an Authorization: Bearer gk_… header.
claude mcp add --transport http gelee https://web.gelee.ai/api/v1/mcp \
--header "Authorization: Bearer gk_your_key"
Tools come in groups: campaigns, leads, messaging, sdr, inbox, workspace, plus the reading tools that are always on. A key-based connection can load fewer by adding ?toolsets=campaigns,leads to the address.
What it will and will not do
Every change is a proposal you confirm. Launching a campaign and approving a reply draft are real LinkedIn actions and cannot be undone, and the assistant says so first. It never writes outbound text itself: a reply draft goes out exactly as the AI SDR drafted it, through the same checks as the app. It never quotes prices. Anything a lead wrote is treated as data, never as instructions.
Auth and limits
TopicWhat to know
AuthenticationAuthorization: Bearer gk_… on every request. One key belongs to one organization and only ever returns that organization's data.
Scopesread is the default and covers every GET. read_write is required for every write: pushing leads or exclusions, marking outcomes, creating or duplicating campaigns, sending conversion events. A read key gets 403 on those.
Rate limit120 requests per minute per organization, shared across all of that organization's keys. GET /inbox is 30 per minute because it calls LinkedIn live.
Paginationlimit (default 50, max 200) and offset. Responses carry a pagination block with limit, offset and total.
VersioningEvery path starts with /api/v1. Breaking changes ship as a new version.
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1786988000
Retry-After: 12
All endpoints
GET/meCheck a key and see its organization.GET/campaignsCampaigns with prospect counts by status.GET/campaigns/{id}One campaign, including its sequence and schedule.GET/prospectsProspects, filterable by campaign and status.GET/prospects/{id}One prospect.PATCH/prospects/{id}Mark a prospect booked, converted, interested or lost.POST/campaignsCreate a campaign, as a draft.POST/campaigns/{id}/duplicateCopy a campaign's setup into a new draft.GET/analyticsTotals across the organization.GET/analytics/dailyOne row per day: invites, replies, bookings.GET/needs-humanReplies the AI SDR handed back to a person.GET/stalled-leadsReplied positively, then went quiet.GET/inboxRecent LinkedIn conversations, live.POST/campaigns/{id}/leadsPush your own leads into a campaign.GET/campaigns/{id}/leadsCheck how a whole push landed.GET/leads/{id}One lead: enrichment state and verdict.POST/blacklistAdd people or companies to do-not-contact.GET/blacklistRead the do-not-contact list.POST/webhooks/conversionsSend your own conversion events in.POST/webhooks/leads/{token}No-code intake. One pasteable URL, no key. The last two sit at www.gelee.ai/api/webhooks/…, outside the /v1 prefix.
Reading data
GET/campaigns
Every campaign in the organization, newest first, each with a count of prospects per status.
{ "data": [
{ "id": "9a2c…", "name": "Q2 Founders", "status": "active",
"outreach_type": "connect",
"created_at": "2026-04-02T10:00:00.000Z",
"updated_at": "2026-06-11T18:22:00.000Z",
"prospect_counts": { "messaged": 120, "replied": 18, "completed": 9 } }
] }GET /campaigns/{id} returns the same fields plus the campaign's full sequence and schedule.
GET/prospects
People in your campaigns. This is the endpoint most CRM syncs poll.
ParameterRequiredNotes
campaign_idnoOnly prospects in this campaign.
statusnoExact match, for example replied or connected.
limitnoDefault 50, max 200.
offsetnoDefault 0.
{ "data": [
{ "id": "3f81…", "campaign_id": "9a2c…", "linkedin_id": "ACoAA…",
"name": "Jane Doe", "status": "replied", "current_step": 2,
"linkedin_url": "https://linkedin.com/in/janedoe", "public_identifier": "janedoe",
"role": "VP Sales", "company": "Acme", "headline": "VP Sales at Acme",
"industry": "Computer Software", "location": "Toronto, Ontario, Canada",
"company_website": "https://acme.example",
"company_linkedin_url": "https://linkedin.com/company/acme",
"company_size": "51-200", "follower_count": 4820,
"source": "evergreen_post_engager", "created_at": "2026-06-01T09:14:00.000Z" }
],
"pagination": { "limit": 50, "offset": 0, "total": 342 } }Statuses you will see: pending, active, connection_sent, connected, messaged, replied, opted_out, completed. Treat that as the current set, not a fixed one.
Every list that returns people carries the same block, so you can write one mapper and use it everywhere. That is /prospects, /leads/{id}, /campaigns/{id}/leads, /needs-human, /stalled-leads and /inbox.
FieldWhat it is
linkedin_urlTheir profile. Built only from a real /in/ handle, so it is either a link that works or null, never a guess.
public_identifierThe handle on its own, e.g. janedoe.
roleTheir current job title.
companyWhere they work now.
industryThe company's industry.
locationWhere they are, e.g. Toronto, Ontario, Canada.
headlineTheir LinkedIn headline, as written.
company_websiteThe company's own site.
company_linkedin_urlThe company's LinkedIn page, not the person's profile.
company_sizeHeadcount band as LinkedIn reports it, e.g. 51-200.
follower_countHow many followers they have.
Anything we have not learned about a person comes back as null rather than being left out, so the shape is the same on every row. Roughly: we know the profile URL and company for almost everyone, the title for most, and the industry and company facts for over half. More on people who have been contacted, because those get enriched first.
GET/analytics/daily
One row per UTC day, zero filled, for reporting and dashboards.
ParameterRequiredNotes
fromnoYYYY-MM-DD, inclusive. Defaults to 29 days before to.
tonoYYYY-MM-DD, inclusive. Defaults to today.
campaign_idnoOnly this campaign.
account_idnoOnly this LinkedIn account.
{ "data": [
{ "date": "2026-06-01", "invites_sent": 42, "connections_accepted": 11,
"messages_sent": 23, "replies": 6, "positive_replies": 3,
"negative_replies": 1, "bookings": 2, "invites_withdrawn": 5 }
],
"meta": { "from": "2026-06-01", "to": "2026-06-30", "timezone": "UTC" } }The range can span at most 92 days. Counts cover people from your campaigns, which is what your dashboard shows. positive_replies and negative_replies start from 2026-07-02; before that, replies only count in replies.
GET /analytics is the all-time version: campaigns, total_prospects and prospects_by_status.
GET/needs-human
Replies the AI SDR would not answer on its own. The same list the app shows under Inbox, Needs Human.
ParameterRequiredNotes
campaign_idnoOnly this campaign.
limitnoDefault 50, max 200.
offsetnoDefault 0.
{ "data": [
{ "id": "77c1…", "campaign_id": "9a2c…", "chat_id": "chat_88",
"name": "Jane Doe",
"message": "Can you send pricing to jane@acme.com instead?",
"ai_draft": "Happy to send that over. What is the best address?",
"intent": null,
"reason": "wants_email",
"reason_label": "They asked to be emailed instead",
"waiting_since": "2026-08-09T03:12:00.000Z", "hours_waiting": 6 }
],
"pagination": { "limit": 50, "offset": 0, "total": 3 } }reason is the machine value and reason_label is the same thing in plain English, which is the one to show a person.
reasonreason_label
needs_humanThe SDR could not answer this one
wants_emailThey asked to be emailed instead
ai_calloutThey asked whether they are talking to a bot
auto_bookedA booking attempt needs a person to confirm it
empty_or_too_shortThe draft came back empty
ai_admissionThe draft admitted it was AI
apologyThe draft opened with an apology
leaked_reasoningThe draft leaked its own reasoning
bump_recheck_failedWe could not re-read the thread before following up
blacklist_check_failedWe could not verify this person against the blacklist
GET/stalled-leads
People who replied positively and then went quiet. Use it to create follow-up tasks.
ParameterRequiredNotes
daysnoDays of silence before a lead counts as stalled. Default 5.
campaign_idnoOnly this campaign.
limitnoDefault 50, max 200.
offsetnoDefault 0.
{ "data": [
{ "id": "3f81…", "campaign_id": "9a2c…", "linkedin_id": "ACoAA…",
"name": "Jane Doe", "role": "VP Sales", "company": "Acme",
"headline": "VP Sales at Acme", "public_identifier": "janedoe",
"linkedin_url": "https://linkedin.com/in/janedoe",
"industry": "Computer Software", "location": "Toronto, Ontario, Canada",
"company_website": "https://acme.example",
"company_linkedin_url": "https://linkedin.com/company/acme",
"company_size": "51-200", "follower_count": 4820,
"days_since_activity": 9,
"last_activity_at": "2026-08-01T12:00:00.000Z",
"last_stalled_at": "2026-08-06T12:00:00.000Z",
"sentiment": "positive" }
],
"pagination": { "limit": 50, "offset": 0, "total": 4 },
"threshold_days": 5 }Stalest first, unlike the other lists, so the most urgent follow-up is on top.
GET/inbox
Recent LinkedIn conversations, read live from LinkedIn rather than from our database.
ParameterRequiredNotes
limitnoDefault 50, max 100.
scopenocampaign (default) for people from your campaigns, or all for everything in the connected inboxes.
{ "data": [
{ "id": "chat_88", "account_id": "SExF…", "attendee_name": "Jane Doe",
"attendee_provider_id": "ACoAA…", "unread_count": 1,
"last_message": { "text": "Sounds great!", "at": "2026-08-14T13:04:11.522Z",
"from_me": false },
"linkedin_url": "https://linkedin.com/in/janedoe", "public_identifier": "janedoe",
"role": "VP Sales", "company": "Acme", "industry": "Computer Software",
"location": "Toronto, Ontario, Canada", "headline": "VP Sales at Acme",
"company_website": "https://acme.example",
"company_linkedin_url": "https://linkedin.com/company/acme",
"company_size": "51-200", "follower_count": 4820 }
],
"scope": "campaign" }The person's details come from what we have stored about them, so a stranger who was never in one of your campaigns comes back with the conversation and null for the rest.
Read this before you build on it
This one is live, so it is slower and capped at 30 requests per minute. It reads the first five connected LinkedIn accounts. A seat's inbox also holds personal messages and recruiters, which is why campaign is the default. There is no pagination block here.
Pushing leads
Source people however you like, then let Gelee enrich them, filter them and run the outreach. Works best with an evergreen campaign you top up continuously. Needs a read_write key.
POST/campaigns/{id}/leads
Up to 100 leads per call. The only required field is the LinkedIn URL.
ParameterRequiredNotes
leadsyesArray of lead objects, 1 to 100.
linkedin_urlyesPer lead. A profile URL, a bare slug, or a member id.
first_name, last_name, namenoIf you send the parts, we build the name.
company, titlenoUsed for your filters and exclusions.
email, location, headlinenoStored on the lead.
sourcenoYour own label, so you can tell where a lead came from.
datanoUp to 20 of your own key and value pairs, kept on the lead.
Idempotency-KeynoHeader. Tags the batch so you can reconcile it later.
curl -X POST https://www.gelee.ai/api/v1/campaigns/CAMPAIGN_ID/leads \
-H "Authorization: Bearer gk_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: my-run-42" \
-d '{"leads":[
{"linkedin_url":"https://linkedin.com/in/janedoe",
"first_name":"Jane","last_name":"Doe",
"company":"Acme","title":"VP Sales",
"location":"Toronto, Ontario, Canada",
"source":"reddit-monitor",
"data":{"intent_score":"92"}}
]}'{ "batch_id": "my-run-42",
"campaign_id": "9a2c…",
"accepted": [ { "lead_id": "5d70…", "linkedin_id": "janedoe",
"name": "Jane Doe", "created": true } ],
"rejected": [],
"counts": { "received": 1, "accepted": 1, "rejected": 0, "duplicates": 0 } }202, not 200
The lead is saved immediately, but enrichment and the campaign's full filters resolve later, anywhere from minutes to hours. Anything we can decide on the spot comes back in rejected right away.
Safe to retry. Leads are keyed on campaign plus LinkedIn profile, so re-sending a batch returns the same lead_id values with created: false and creates nothing new. A scheduled full sync is fine.
Why a lead was turned away
reasonMeaning
invalid_linkedin_urlNot a LinkedIn profile URL, slug, or member id.
duplicate_in_requestThe same person appeared twice in one call.
blacklistedOn your do-not-contact list, by profile or by company.
already_in_another_campaignAlready being worked in another of your campaigns.
hard_filterExcluded by this campaign's filters. Comes with a lead_id and the specific reason.
Your do-not-contact list and the check for people already in another campaign always apply. No setting turns them off.
GET/campaigns/{id}/leads
Check how a whole push landed, instead of polling each lead one at a time.
ParameterRequiredNotes
batch_idnoYour Idempotency-Key, or the batch_id you got back.
sourcenoPass all to include leads added any other way, not just pushed ones.
limitnoDefault 50, max 200.
offsetnoDefault 0.
{ "data": [
{ "lead_id": "5d70…", "campaign_id": "9a2c…", "linkedin_id": "janedoe",
"name": "Jane Doe", "status": "active", "contacted": false,
"enrichment_status": "done",
"filter_verdict": "accepted", "filter_reason": null,
"batch_id": "my-run-42", "source": "api_intake",
"company": "Acme", "role": "VP Sales", "location": "Toronto, Ontario, Canada",
"linkedin_url": "https://linkedin.com/in/janedoe", "public_identifier": "janedoe",
"industry": "Computer Software", "headline": "VP Sales at Acme",
"company_website": "https://acme.example",
"company_linkedin_url": "https://linkedin.com/company/acme",
"company_size": "51-200", "follower_count": 4820,
"created_at": "…", "updated_at": "…" }
],
"pagination": { "limit": 50, "offset": 0, "total": 1 } }GET /leads/{id} returns one lead in exactly this shape.
filter_verdictMeaning
pendingNot enriched yet, so the filters have not had the data to judge.
acceptedEnriched and cleared for outreach.
rejectedExcluded. filter_reason says why.
filter_reason is always recalculated against your filters as they stand right now, never a stale snapshot. When a rejected lead comes back with filter_unknown_only: true, it failed only on data we never learned, not on a proven mismatch. To be told instead of asking, subscribe to lead_accepted and lead_rejected.
Marking outcomes
PATCH/prospects/{id}
Tell Gelee what happened with a prospect, so your CRM and your Gelee reporting agree. One field. Needs a read_write key.
ParameterRequiredNotes
outcomeyesExactly one of booked, converted, interested, lost.
outcomeWhat it does
interestedTags them Interested, in the app and in analytics.
lostTags them Lost, and stops all outreach to them.
bookedRecords a real booking: it counts in your booking stats and fires the call_booked webhook, marked as manual so you can always tell it apart from a calendar-detected booking. Their sequence ends and any scheduled follow-ups close.
convertedRecords a conversion of your organization's primary metric, the same thing the Converted label and conversion reporting read.
curl -X PATCH https://www.gelee.ai/api/v1/prospects/PROSPECT_ID \
-H "Authorization: Bearer gk_xxx" \
-H "Content-Type: application/json" \
-d '{"outcome":"booked"}'{ "data": { "id": "3f81…", "outcome": "booked", "applied": "written" } }Safe to retry. Marking someone booked or converted a second time records nothing new and answers with "applied": "already_set", so a scheduled CRM sync cannot inflate your numbers. These are the same writes the outcome buttons in the app's inbox panel make, through the same code. To stop messaging someone without judging the outcome, use POST /inbox/stop below. To never contact them again, POST /inbox/block.
Creating campaigns
POST/campaigns
Create a campaign. It lands as a draft: review the targeting and messages in the app, then launch it there. Needs a read_write key.
The only required field is name. Everything else defaults to what the in-app create does: sends every day 06:00 to 23:00, daily limit 20, and your organization's default filters stamped on and enforced.
ParameterRequiredNotes
nameyesThe campaign name.
outreach_typenooutbound (default, starts with a connection request) or inbound (starts with a message to people already connected).
source_typenoHow the audience will be sourced: custom_list (CSV or pushed leads), linkedin_search, sales_navigator, search_url, post_engagers, post_commenters, post_keywords, website_visitors.
daily_limitnoDefault 20.
scheduleno{ timezone, workingDays, startTime, endTime }.
sequencenoThe message steps, same shape GET /campaigns/{id} returns. Attachments on steps are checked and anything unsafe is stripped.
meta.hard_filtersnoWho the campaign may contact. Validated on the spot: an impossible filter comes back as a 422 naming the issue.
source_confignoSource settings, for example a search URL.
curl -X POST https://www.gelee.ai/api/v1/campaigns \
-H "Authorization: Bearer gk_xxx" \
-H "Content-Type: application/json" \
-d '{"name":"Q4 Founders","outreach_type":"outbound","source_type":"custom_list"}'{ "ok": true, "item": { "id": "b41d…", "name": "Q4 Founders", "status": "draft", … } }A common pair: create a custom_list draft here, then push people into it with POST /campaigns/{id}/leads.
POST/campaigns/{id}/duplicate
Copy a campaign you already like. The clone gets the whole setup: sequence and messages, schedule, filters, AI settings, and the LinkedIn seats. It does not get the original's leads or stats: it arrives as a fresh draft named after the original with (copy). No body needed. Needs a read_write key.
curl -X POST https://www.gelee.ai/api/v1/campaigns/CAMPAIGN_ID/duplicate \
-H "Authorization: Bearer gk_xxx"
{ "ok": true, "item": { "id": "c52e…", "name": "Q4 Founders (copy)", "status": "draft", … } }Do not contact
POST/blacklist
Push your exclusions: current customers, open deals, churned accounts. Enforced across the whole organization.
ParameterRequiredNotes
entriesyesArray of entries, 1 to 500.
valueyesPer entry. A LinkedIn profile URL, a company URL, or a company name.
typeyesPer entry. Exactly account for a person, or company.
curl -X POST https://www.gelee.ai/api/v1/blacklist \
-H "Authorization: Bearer gk_xxx" \
-H "Content-Type: application/json" \
-d '{"entries":[
{"value":"https://linkedin.com/in/janedoe","type":"account"},
{"value":"Acme Corp","type":"company"}
]}'{ "added": 2, "already_present": 0, "rejected": [],
"counts": { "received": 2, "accepted": 2, "rejected": 0 } }Checked when a lead enters a campaign and again before anything is sent. Re-sending a list you already pushed is a no-op, so a nightly full sync from your CRM is fine. Companies match on the name as well as the LinkedIn company URL. Read it back with GET /blacklist?type=company.
POST/inbox/stop
Stop messaging someone: no more drafted replies, scheduled follow-ups cancelled, and they are paused in whatever campaign sequence they are in. Needs a read_write key.
ParameterRequiredNotes
linkedin_urlsyesArray of profile URLs, 1 to 200. A member id works too.
linkedin_urlnoA single URL, if you prefer one per call.
curl -X POST https://www.gelee.ai/api/v1/inbox/stop \
-H "Authorization: Bearer gk_xxx" \
-H "Content-Type: application/json" \
-d '{"linkedin_urls":["https://www.linkedin.com/in/janedoe"]}'{ "data": [ { "input": "https://www.linkedin.com/in/janedoe",
"matched_conversations": 1, "replies_stopped": true,
"followups_closed": 2, "campaigns_paused": 2,
"not_found": false } ],
"requested": 1, "applied": 1 }Usually you are calling this because a rep booked the call over the phone: the prospect never replied on LinkedIn, so nothing was waiting on a reply and the rest of the sequence would keep sending at someone who is already booked. It PAUSES rather than ends, so their campaign membership is kept and undoing it in Gelee puts them back exactly where they were. campaigns_paused counts the sequences they came out of; not_foundmeans Gelee holds nothing for them at all, which is not an error. One bad entry never fails the batch.
POST/inbox/block
The permanent version. Adds them to your block list, stops replies, and cancels follow-ups. Needs a read_write key.
ParameterRequiredNotes
linkedin_urlsyesArray of profile URLs, 1 to 200. A member id works too.
linkedin_urlnoA single URL, if you prefer one per call.
reasonnoYour own label, stored against the block list entry.
curl -X POST https://www.gelee.ai/api/v1/inbox/block \
-H "Authorization: Bearer gk_xxx" \
-H "Content-Type: application/json" \
-d '{"linkedin_urls":["https://www.linkedin.com/in/janedoe"],
"reason":"crm_do_not_contact"}'{ "data": [ { "input": "https://www.linkedin.com/in/janedoe", "blocked": true,
"matched_conversations": 1, "followups_closed": 2,
"drafts_dismissed": 1 } ],
"requested": 1, "blocked": 1 }Because the block list is checked when leads are added and again before anything is sent, this also pulls them out of campaigns that are already running. To undo it, remove them from the block list in Settings, so treat it as permanent. POST /blacklist above is the bulk list-sync version of the same idea: use that for a nightly CRM sync, and this when you also want the live conversation stopped.
Conversions
POST/api/webhooks/conversions
Tell Gelee when someone converted on your side, so it shows up in your Gelee reporting. Needs a read_write key.
ParameterRequiredNotes
event_typeyesYour own label, for example demo_booked or signup.
linkedin_idnoTies the event to a person Gelee already knows.
prospect_namenoUsed to match when you have no LinkedIn id.
prospect_emailnoUsed to match when you have no LinkedIn id.
metanoAny extra JSON you want stored with the event.
curl -X POST https://www.gelee.ai/api/webhooks/conversions \
-H "Authorization: Bearer gk_xxx" \
-H "Content-Type: application/json" \
-d '{"event_type":"demo_booked",
"linkedin_id":"ACoAA…",
"prospect_email":"jane@acme.com",
"meta":{"plan":"pro"}}'{ "ok": true, "event_type": "demo_booked" }The same event sent twice within 24 hours is ignored, and the response comes back with "deduped": true. The meta field is not part of that check, so changing it does not make a repeat count as new.
No-code URL
If your tool only lets you paste a URL, use a campaign's intake URL instead of the endpoint above. No key, no headers, no body shape to match. Create it in the campaign's Settings, under Lead intake URL.
curl -X POST https://web.gelee.ai/api/webhooks/leads/YOUR_URL_TOKEN \
-H "Content-Type: application/json" \
-d '{"linkedin_url":"https://linkedin.com/in/janedoe",
"first_name":"Jane","company":"Acme","title":"VP Sales"}'It reads whatever your tool sends. Field names are matched loosely, so Profile URL, profile_url and profileUrl are all the same thing.
We look forNames that work
The profile (required)linkedin_url, linkedin, profile_url, url, link, public_identifier, Person Linkedin Url
The personfirst_name, last_name, name, full_name, display_name
The companycompany, company_name, organization, employer, account_name
The roletitle, job_title, role, position
The restemail, location, city, country, headline, bio, source, list_name
Send one person as a plain object, a bare list, or { "leads": [ … ] }. Up to 100 per call. Extra columns of your own are kept on the lead. Rows we could not read come back counted as unreadable, so a partial success is never mistaken for a full one. Everything after that is identical to the endpoint above.
Treat the URL like a password
Anyone holding it can add people to that one campaign. It cannot read anything and works for nothing else. Replace or revoke it in the campaign's settings and the old one stops working immediately.
Webhooks
Rather than polling, have Gelee call you. Add a destination under Settings, Event Destinations, point it at any HTTPS endpoint or a Discord channel, and tick the events you want. Up to 10 destinations. The Test button fires a sample so you can map fields before real data flows.
Events you can subscribe to
GroupEvents
Repliesreply_received, reply_positive, reply_negative, needs_human
Connectionsinvite_sent, connection_accepted, invite_withdrawn
Messagesmessage_sent
Bookingscall_booked, auto_booking_created, auto_booking_failed
Stalled leadsconversation_stalled
Campaignscampaign_started, campaign_completed, prospect_added
Lead intakelead_accepted, lead_rejected
Contentpost_published, post_scheduled
Alertsaccount_rate_limited, account_disconnected, account_reconnected, sales_nav_unavailable, sales_nav_restored, report_generated, market_analysis_generated, daily_digest
Subscribe to all to get everything. Subscribing to reply_received also delivers reply_positive and reply_negative.sales_nav_unavailable means Sales Navigator lapsed or its session died on a seat — classic LinkedIn still works (unlike account_disconnected).
What we send
{
"event": "needs_human",
"timestamp": "2026-08-14T13:04:11.522Z",
"org_id": "0f2c…",
"data": {
"prospect": { "name": "Dana Reyes", "linkedin_url": "https://linkedin.com/in/dana",
"linkedin_id": "ACoAA…", "lead_id": "5d70…",
"company": "Acme", "job_title": "VP Sales",
"industry": "Computer Software", "location": "Toronto, Ontario, Canada",
"company_website": "https://acme.example",
"company_linkedin_url": "https://linkedin.com/company/acme",
"company_size": "51-200", "follower_count": 4820 },
"message": { "text": "Can you email me instead?", "chat_id": "chat_88" },
"campaign": { "id": "9a2c…", "name": "Founders Q3" },
"needs_human": {
"reason": "wants_email",
"reason_label": "They asked to be emailed instead",
"ai_draft": "Happy to. What address?",
"inbox_url": "https://web.gelee.ai/inbox?tab=human"
}
}
}Every event uses that envelope. Which blocks appear inside data depends on the event:
BlockAppears on
prospectAnything about a person: replies, invites, messages, bookings, lead verdicts. Their profile URL, job title, company, industry, location, headline, and the company's website, LinkedIn page, size and their follower count. A field we do not know is left out, rather than sent as null.
messageReply and message events. Carries text, chat_id, sentiment and recent conversation.
campaignAnything tied to a campaign, with id, name and stats.
bookingcall_booked and the auto booking events: start_time, provider, title.
leadlead_accepted and lead_rejected: batch_id, and reason when rejected.
needs_humanneeds_human: reason, reason_label, ai_draft, queue_id, inbox_url.
stalledconversation_stalled: days_since_activity and the recent conversation.
reportdaily_digest and report_generated: type, period, share_url, stats.
accountaccount_rate_limited, account_disconnected, account_reconnected, sales_nav_unavailable, sales_nav_restored: name, account_id, reason, reconnect_url; plus cooldown_hours on rate limits, status/disconnected_at on disconnect, sn_verdict/previous_sn_verdict/sn_changed_at on Sales Navigator.
postpost_published and post_scheduled: text, url, scheduled_time.
Verifying it came from us
Set a signing secret on the destination and every request carries an X-Gelee-Signature header: an HMAC SHA-256 of the raw body, in hex. Without a secret, no signature is sent.
import { createHmac, timingSafeEqual } from "crypto";
// rawBody must be the UNPARSED string. Re-serialising JSON changes the bytes.
const expected = createHmac("sha256", process.env.GELEE_WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const ok = timingSafeEqual(
Buffer.from(expected),
Buffer.from(req.headers["x-gelee-signature"] ?? ""),
);DeliveryBehaviour
RetriesFour attempts in total. Anything that is not a 2xx is retried.
Timeout10 seconds. Reply 2xx as soon as you have the payload and do your work after.
DuplicatesIdentical payloads to the same destination collapse into one delivery.
VisibilityThe last attempt, its HTTP code and your endpoint's own error show next to the destination in Settings.
Reply events only cover your campaigns
reply_received, reply_positive and reply_negative fire only for people you reached through a Gelee campaign, so personal messages and recruiters in the same inbox stay out of your CRM. needs_human is deliberately not filtered that way: if someone needs an answer, you should hear about it either way.
Errors
{ "error": { "code": "invalid_api_key", "message": "Invalid API key." } }StatusMeaning
400Something in the request is wrong. The message names it.
401Missing, invalid, or expired key.
403A read key was used on a write endpoint.
404Not found, or not in your organization.
409The campaign is completed or archived and cannot take new leads.
429Rate limited. Wait for Retry-After and try again.
5xxOur side. Retry with backoff.
Two older endpoints, /analytics/daily and /webhooks/conversions, and all 429 responses return a plain { "error": "…" } string instead of the coded shape. Read error defensively if you parse it.
Writes need a
read_write key. Keys made before write support existed are
read, so generate a new one in
Settings, Integrations, API Keys. Questions:
hello@gelee.ai