New The infrastructure of cold email has come to LinkedIn. See how it works →
Your market is moving
Curious what your LinkedIn TAM is? See it live on a call.
Book a discovery call →
Geleev1

Gelee API

Pull your campaigns, prospects, replies and analytics into your own tools. Push your leads and your do-not-contact list back in, and Gelee runs the LinkedIn outreach.

Base URLwww.gelee.ai/api/v1
FormatJSON
AuthBearer key
Using an AI agent?
Take the whole reference as Markdown and paste it into your agent's context. Agents can also find it at www.gelee.ai/llms.txt.
Download api.md

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:

bash
curl https://www.gelee.ai/api/v1/me \
  -H "Authorization: Bearer gk_xxxxxxxxxxxxxxxx"
200 response
{ "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.
429 response headers
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.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.

200 response
{ "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.
200 response
{ "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.
200 response
{ "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.
200 response
{ "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.
200 response
{ "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.
200 response
{ "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.
bash
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"}}
      ]}'
202 response
{ "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.
200 response
{ "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.
bash
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"}
      ]}'
200 response
{ "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.
bash
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"}}'
200 response
{ "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.

bash
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

POST your endpoint
{
  "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.

node
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 shape
{ "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