Reference
Webhooks
Rather than polling, have Gelee call you. Point a destination at any public HTTPS endpoint or a Discord channel and pick the events you want. Every event carries the person, the seat and the campaign.
Events
| Group | Events |
|---|---|
| Replies | reply_received, reply_positive, reply_negative, needs_human |
| Connections | invite_sent*, connection_accepted, invite_withdrawn |
| Messages | message_sent* |
| Bookings | call_booked, auto_booking_created, auto_booking_failed |
| Leads | lead_accepted, lead_rejected, lead_updated |
| Campaigns | campaign_started, campaign_completed |
| Content | post_published, post_scheduled |
| Alerts | account_rate_limited, account_disconnected, account_reconnected, sales_nav_unavailable, sales_nav_restored, report_generated, report_sends_skipped, daily_digest |
invite_sent and message_sent fire for every invite and message a seat sends, from a campaign or the API. That is hundreds a day per seat, so subscribing to all leaves them out.
reply_receivedalso deliversreply_positiveandreply_negative.- Reply events only cover people you reached through a Gelee campaign, so personal messages and recruiters stay out of your CRM.
needs_humanis not filtered: if someone needs an answer, you hear about it. lead_updatedfires when a lead's custom fields, tags, flags or notes change, or it is paused, resumed or removed, through the API or the assistant.sales_nav_unavailablemeans Sales Navigator lapsed or its session died on a seat. Classic LinkedIn still works, unlikeaccount_disconnected.
What we send
Every event uses one envelope. Which blocks appear inside data depends on the event.
{
"event": "reply_received",
"timestamp": "2026-10-22T13:04:11.522Z",
"org_id": "0f2c…",
"data": {
"prospect": { "name": "Dana Reyes", "lead_id": "5d70…",
"linkedin_url": "https://www.linkedin.com/in/dana",
"linkedin_id": "ACoAA…", "job_title": "VP Sales",
"company": "Acme", "industry": "Computer Software",
"location": "Toronto, Ontario, Canada" },
"sender": { "name": "Sofia Salazar", "account_id": "SExF…",
"public_identifier": "sofiasalazar",
"linkedin_url": "https://www.linkedin.com/in/sofiasalazar" },
"campaign": { "id": "9a2c…", "name": "Q4 Founders" },
"message": { "text": "Sounds good, send times?", "chat_id": "chat_88",
"sentiment": "positive" }
}
}| Block | On | Fields |
|---|---|---|
prospect | Anything about a person | name, lead_id, linkedin_url, linkedin_id, job_title, company, industry, location, email, headline, company_website, company_linkedin_url, company_size, follower_count |
sender | Anything a seat did | name, account_id, public_identifier, linkedin_url |
campaign | Anything tied to a campaign | id, name, stats |
message | Replies and sends | text, subject, message_type, sentiment, chat_id, conversation |
booking | call_booked, auto bookings | start_time, provider, title |
lead | lead_accepted, lead_rejected | batch_id, reason |
lead_update | lead_updated | change (fields, tags, flags, note, paused, resumed, removed), custom_fields, tags, flags, by (api or assistant) |
needs_human | needs_human | reason, reason_label, ai_draft, queue_id, inbox_url |
account | Seat alerts | name, account_id, reason, reconnect_url, plus cooldown_hours, status, sn_verdict and similar |
report | daily_digest, report_generated | type, period, share_url, stats |
post | post_published, post_scheduled | text, url, scheduled_time |
Webhooks say job_title where the REST API says role. A field we do not know is left out of a webhook rather than sent as null, so check a key exists before reading it.
Verify the signature
A destination with a signing secret signs every request. Read X-Gelee-Signature-256: t=<unix seconds>,v1=<hex>, where v1 is an HMAC SHA-256 of <t>.<raw body>. The time is signed too, so reject anything older than 5 minutes and a captured request cannot be replayed. The older X-Gelee-Signature (HMAC of the body alone) is still sent.
import { createHmac, timingSafeEqual } from "crypto";
// rawBody must be the UNPARSED request body. Re-serialising JSON changes the bytes.
export function verifyGelee(rawBody, header, secret) {
const t = /t=(\d+)/.exec(header ?? "")?.[1];
const v1 = /v1=([0-9a-f]+)/.exec(header ?? "")?.[1] ?? "";
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5 min
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return v1.length === expected.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
// verifyGelee(rawBody, req.headers["x-gelee-signature-256"], process.env.GELEE_WEBHOOK_SECRET)import hmac, hashlib, re, time
def verify_gelee(raw_body: bytes, header: str, secret: str) -> bool:
t = re.search(r"t=(\d+)", header or "")
v1 = re.search(r"v1=([0-9a-f]+)", header or "")
if not t or not v1 or abs(time.time() - int(t.group(1))) > 300:
return False
signed = t.group(1).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1.group(1))Delivery
| Behaviour | |
|---|---|
| Retries | Four attempts. Anything that is not a 2xx is retried. |
| Timeout | 10 seconds. Answer 2xx as soon as you have the payload and do the work after. |
| Duplicates | Identical payloads to the same destination collapse into one delivery. |
| Visibility | The last attempt, its HTTP code and your endpoint's error show next to the destination in Settings. |
| Safety | Private network, localhost and cloud metadata addresses are refused, including as a redirect target. Up to three redirects to public addresses are followed. |
Manage destinations from code
The same destinations as Settings, Event Destinations. Up to 10 per workspace.
all. GET /webhooks/events lists them with what each means.A webhook made through the API is always signed. Store signing_secret from this answer: it cannot be read back. Rotate it with PATCH /webhooks/{id} { "rotate_secret": true }.
| Call | What it does |
|---|---|
GET /webhooks | Your destinations. URLs show only their last characters. |
PATCH /webhooks/{id} | Change label, events, active or url (tested first), or rotate the secret. |
DELETE /webhooks/{id} | Remove it. |
POST /webhooks/{id}/test?event=… | Send a full, signed sample of that event with "test": true, so you can map every field before real data flows. |
curl -X POST "https://www.gelee.ai/api/v1/webhooks" \
-H "Authorization: Bearer gk_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://crm.example.com/hooks/gelee",
"events": [
"reply_received",
"call_booked",
"lead_updated"
],
"label": "CRM"
}'{ "data": { "id": "d1f0…", "label": "CRM", "type": "webhook",
"url_preview": "...ks/gelee",
"events": ["reply_received", "call_booked", "lead_updated"],
"active": true, "scope": "all", "signed": true,
"signing_secret": "whsec_3b9…" } }