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.
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 to push leads, push exclusions, or send 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
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,
"headline": "VP Sales at Acme", "public_identifier": "janedoe",
"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.
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",
"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 } }
],
"scope": "campaign" }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",
"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.
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.
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, report_generated, market_analysis_generated, daily_digest
Subscribe to all to get everything. Subscribing to reply_received also delivers reply_positive and reply_negative.
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",
"company": "Acme", "job_title": "VP Sales" },
"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.
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: name, cooldown_hours, reason.
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