Webhooks
Webhooks
Gemlead sends an HTTPS request to your server the moment a lead is found, turns hot, replies or reacts to your message, or Sam AI has a reply ready, sends one or hands you a conversation, signed so you know it comes from us.
Set up an endpoint
Add an endpoint in the app under API, or with Create a webhook endpoint. Give it a public HTTPS URL and the event types you want, and keep the secret it returns to verify signatures.
- HTTPS only, no credentials in the URL, and no private, loopback or link local address.
- Up to 10 endpoints per account.
filters.persona_idslimits the events an endpoint gets to the leads of some personas, whatever their type.- Send a test event to check your server before real events arrive.
Webhooks come with the Business and Enterprise plans. If the account moves to Free or Starter, its endpoints are kept but send nothing until it is back on Business or Enterprise.
curl -X POST https://gemlead.ai/api/v1/webhook_endpoints \
-H "Authorization: Bearer $GEMLEAD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/gemlead",
"events": ["lead.hot", "reply.received"],
"description": "CRM sync",
"filters": {
"persona_ids": ["lvWBPnytpS"]
}
}'Event types
Every event has the same envelope. Its data depends on its type.
| Type | Sent when |
|---|---|
lead.created | Gemlead finds and scores a new lead, whatever its score. Each lead sends it once, with its first score, and only within 3 days of being found, so a rescore of an older lead never sends it. A lead scored 60 or more right away sends lead.hot too. |
lead.hot | A lead's score reaches 60 or more for the first time. Each lead sends it once, a rescore never sends it again. |
reply.received | A lead sends you a new message, in a campaign or not. Each message sends it once, if it is less than 6 hours old. When many of the lead's messages come in at once, only the 5 newest send it. |
reaction.received | A lead reacts to one of your messages with an emoji. Each emoji of the lead on a message sends it once. |
sam.suggestion.ready | Sam AI has a new reply ready for you to send, in a conversation in draft mode. Each suggestion sends it once, if it is less than 6 hours old. |
sam.reply.sent | A reply of Sam AI goes out from your X account, on its own in auto mode or once you approve it in the app. Each reply sends it once, if it is less than 6 hours old. The messages you write yourself never send it, they come back in Retrieve a conversation. |
sam.handoff | Sam AI hands a conversation to you, when it sees no good reply without you, the lead sent only a photo or a voice note, or its reply could not go out. In auto mode it then leaves the lead's next messages to you. Each handoff sends it once, if it is less than 6 hours old. |
The event object
- idstringUnique identifier for the event,
evt_and 24 characters. Also in theGemlead-Event-Idheader. - objectstringAlways
event. - typestringThe event type.
lead.createdlead.hotreply.receivedreaction.receivedsam.suggestion.readysam.reply.sentsam.handoff - created_atstringWhen the event happened, ISO 8601.
- account_idstringThe Gemlead account it happened on.
- dataobjectThe event's content, which depends on its type.
lead.created
Gemlead finds and scores a new lead, whatever its score. Each lead sends it once, with its first score, and only within 3 days of being found, so a rescore of an older lead never sends it. A lead scored 60 or more right away sends lead.hot too.
Data
- objectobjectThe lead, as Retrieve a lead returns it.
{
"id": "evt_8kQ2vXr4LmT9wPz1Hc6YbN3d",
"object": "event",
"type": "lead.created",
"created_at": "2026-11-05T14:03:40.117Z",
"account_id": "a9PzK2vRtM",
"data": {
"object": {
"id": "k3VnQ8wZpR",
"object": "lead",
"profile": {
"x_user_id": "1592047713",
"username": "mark_picks",
"name": "Mark Ellis",
"bio": "Free NBA picks every day. Sports fan first.",
"followers_count": 2150,
"following_count": 940,
"location": "Denver, CO",
"website": "https://markpicks.net",
"profile_image_url": "https://pbs.twimg.com/profile_images/1592047713/mark_400x400.jpg",
"verified": false,
"url": "https://x.com/mark_picks"
},
"score": 38,
"score_reason": "Shares free picks for fun and sells nothing yet, no sign of a paying community.",
"hot": false,
"status": "scored",
"persona": {
"id": "lvWBPnytpS",
"name": "Tipsters"
},
"campaign": null,
"conversation_id": null,
"locked_to": null,
"found_at": "2026-11-05T14:02:11.482Z",
"scored_at": "2026-11-05T14:03:40.117Z",
"updated_at": "2026-11-05T14:03:40.117Z",
"metadata": {}
}
}
}lead.hot
A lead's score reaches 60 or more for the first time. Each lead sends it once, a rescore never sends it again.
Data
- objectobjectThe lead, as Retrieve a lead returns it.
{
"id": "evt_8kQ2vXr4LmT9wPz1Hc6YbN3d",
"object": "event",
"type": "lead.hot",
"created_at": "2026-11-05T14:03:40.117Z",
"account_id": "a9PzK2vRtM",
"data": {
"object": {
"id": "msgOtJ8dvT",
"object": "lead",
"profile": {
"x_user_id": "1460283925",
"username": "jane_builds",
"name": "Jane Doe",
"bio": "Runs a paid trading community. Daily setups, no hype.",
"followers_count": 12400,
"following_count": 380,
"location": "Austin, TX",
"website": "https://janedoe.com",
"profile_image_url": "https://pbs.twimg.com/profile_images/1460283925/jane_400x400.jpg",
"verified": true,
"url": "https://x.com/jane_builds"
},
"score": 86,
"score_reason": "Runs a paid trading community and asks how to collect payments for it.",
"hot": true,
"status": "scored",
"persona": {
"id": "lvWBPnytpS",
"name": "Tipsters"
},
"campaign": null,
"conversation_id": null,
"locked_to": null,
"found_at": "2026-11-05T14:02:11.482Z",
"scored_at": "2026-11-05T14:03:40.117Z",
"updated_at": "2026-11-05T14:03:40.117Z",
"metadata": {}
}
}
}reply.received
A lead sends you a new message, in a campaign or not. Each message sends it once, if it is less than 6 hours old. When many of the lead's messages come in at once, only the 5 newest send it.
Data
- leadobjectThe lead who wrote.
- messageobject
- campaignobject or nullThe campaign the lead is in, with its
id,nameandstatus. - conversation_idstringUse it with Retrieve a conversation for the whole thread.
{
"id": "evt_8kQ2vXr4LmT9wPz1Hc6YbN3d",
"object": "event",
"type": "reply.received",
"created_at": "2026-11-05T14:03:40.117Z",
"account_id": "a9PzK2vRtM",
"data": {
"lead": {
"id": "msgOtJ8dvT",
"object": "lead",
"profile": {
"x_user_id": "1460283925",
"username": "jane_builds",
"name": "Jane Doe",
"bio": "Runs a paid trading community. Daily setups, no hype.",
"followers_count": 12400,
"following_count": 380,
"location": "Austin, TX",
"website": "https://janedoe.com",
"profile_image_url": "https://pbs.twimg.com/profile_images/1460283925/jane_400x400.jpg",
"verified": true,
"url": "https://x.com/jane_builds"
},
"score": 86,
"score_reason": "Runs a paid trading community and asks how to collect payments for it.",
"hot": true,
"status": "in_conversation",
"persona": {
"id": "lvWBPnytpS",
"name": "Tipsters"
},
"campaign": {
"id": "c7RkQ2wLxe",
"name": "Tipsters warm intro",
"status": "in_progress"
},
"conversation_id": "msgOtJ8dvT",
"locked_to": null,
"found_at": "2026-11-05T14:02:11.482Z",
"scored_at": "2026-11-05T14:03:40.117Z",
"updated_at": "2026-11-05T14:03:40.117Z",
"metadata": {}
},
"message": {
"id": "1985321409876543210",
"object": "message",
"direction": "inbound",
"text": "Yes, I want to charge for my Discord. How does it work?",
"created_at": "2026-11-06T08:12:44.000Z",
"media": [],
"reactions": [],
"encrypted": false,
"reply_to_message_id": null
},
"campaign": {
"id": "c7RkQ2wLxe",
"name": "Tipsters warm intro",
"status": "in_progress"
},
"conversation_id": "msgOtJ8dvT"
}
}reaction.received
A lead reacts to one of your messages with an emoji. Each emoji of the lead on a message sends it once.
{
"id": "evt_8kQ2vXr4LmT9wPz1Hc6YbN3d",
"object": "event",
"type": "reaction.received",
"created_at": "2026-11-05T14:03:40.117Z",
"account_id": "a9PzK2vRtM",
"data": {
"lead": {
"id": "msgOtJ8dvT",
"object": "lead",
"profile": {
"x_user_id": "1460283925",
"username": "jane_builds",
"name": "Jane Doe",
"bio": "Runs a paid trading community. Daily setups, no hype.",
"followers_count": 12400,
"following_count": 380,
"location": "Austin, TX",
"website": "https://janedoe.com",
"profile_image_url": "https://pbs.twimg.com/profile_images/1460283925/jane_400x400.jpg",
"verified": true,
"url": "https://x.com/jane_builds"
},
"score": 86,
"score_reason": "Runs a paid trading community and asks how to collect payments for it.",
"hot": true,
"status": "in_conversation",
"persona": {
"id": "lvWBPnytpS",
"name": "Tipsters"
},
"campaign": {
"id": "c7RkQ2wLxe",
"name": "Tipsters warm intro",
"status": "in_progress"
},
"conversation_id": "msgOtJ8dvT",
"locked_to": null,
"found_at": "2026-11-05T14:02:11.482Z",
"scored_at": "2026-11-05T14:03:40.117Z",
"updated_at": "2026-11-05T14:03:40.117Z",
"metadata": {}
},
"message": {
"id": "1985329981234567890",
"object": "message",
"direction": "outbound",
"text": "Happy to show you. Do you sell picks monthly or per event?",
"created_at": "2026-11-06T08:20:03.000Z",
"media": [],
"reactions": [
{
"emoji": "👍",
"direction": "inbound",
"created_at": "2026-11-06T08:24:51.000Z"
}
],
"encrypted": false,
"reply_to_message_id": "1985321409876543210"
},
"reaction": {
"emoji": "👍",
"direction": "inbound",
"created_at": "2026-11-06T08:24:51.000Z"
}
}
}sam.suggestion.ready
Sam AI has a new reply ready for you to send, in a conversation in draft mode. Each suggestion sends it once, if it is less than 6 hours old.
Data
- leadobjectThe lead Sam AI answers.
- conversation_idstringUse it with Retrieve a conversation's Sam AI to read the suggestion again or change the mode.
- campaignobject or nullThe campaign the lead is in, with its
id,nameandstatus. - suggestionobjectThe reply Sam AI has ready.
{
"id": "evt_8kQ2vXr4LmT9wPz1Hc6YbN3d",
"object": "event",
"type": "sam.suggestion.ready",
"created_at": "2026-11-05T14:03:40.117Z",
"account_id": "a9PzK2vRtM",
"data": {
"lead": {
"id": "msgOtJ8dvT",
"object": "lead",
"profile": {
"x_user_id": "1460283925",
"username": "jane_builds",
"name": "Jane Doe",
"bio": "Runs a paid trading community. Daily setups, no hype.",
"followers_count": 12400,
"following_count": 380,
"location": "Austin, TX",
"website": "https://janedoe.com",
"profile_image_url": "https://pbs.twimg.com/profile_images/1460283925/jane_400x400.jpg",
"verified": true,
"url": "https://x.com/jane_builds"
},
"score": 86,
"score_reason": "Runs a paid trading community and asks how to collect payments for it.",
"hot": true,
"status": "in_conversation",
"persona": {
"id": "lvWBPnytpS",
"name": "Tipsters"
},
"campaign": {
"id": "c7RkQ2wLxe",
"name": "Tipsters warm intro",
"status": "in_progress"
},
"conversation_id": "msgOtJ8dvT",
"locked_to": null,
"found_at": "2026-11-05T14:02:11.482Z",
"scored_at": "2026-11-05T14:03:40.117Z",
"updated_at": "2026-11-05T14:03:40.117Z",
"metadata": {}
},
"conversation_id": "msgOtJ8dvT",
"campaign": {
"id": "c7RkQ2wLxe",
"name": "Tipsters warm intro",
"status": "in_progress"
},
"suggestion": {
"messages": [
"It takes two minutes. You set a price, members pay, and they get their Discord role right away.",
"Want the link to set it up?"
],
"stage": "answer_question",
"why": "She asked how it works, so the reply explains it in one line and offers the link.",
"replying_to_message_id": "1985321409876543210",
"created_at": "2026-11-06T08:13:20.000Z"
}
}
}sam.reply.sent
A reply of Sam AI goes out from your X account, on its own in auto mode or once you approve it in the app. Each reply sends it once, if it is less than 6 hours old. The messages you write yourself never send it, they come back in Retrieve a conversation.
Data
- leadobjectThe lead Sam AI answered.
- conversation_idstringUse it with Retrieve a conversation for the whole thread.
- campaignobject or nullThe campaign the lead is in, with its
id,nameandstatus. - messagesarray of stringsThe DMs Sam AI sent, in order.
- sent_bystring
autowhen Sam AI sent it on its own,approvedwhen you approved it in the app.autoapproved - replying_to_message_idstring or nullThe lead's message it answered.
- sent_atstringWhen it went out, ISO 8601.
{
"id": "evt_8kQ2vXr4LmT9wPz1Hc6YbN3d",
"object": "event",
"type": "sam.reply.sent",
"created_at": "2026-11-05T14:03:40.117Z",
"account_id": "a9PzK2vRtM",
"data": {
"lead": {
"id": "msgOtJ8dvT",
"object": "lead",
"profile": {
"x_user_id": "1460283925",
"username": "jane_builds",
"name": "Jane Doe",
"bio": "Runs a paid trading community. Daily setups, no hype.",
"followers_count": 12400,
"following_count": 380,
"location": "Austin, TX",
"website": "https://janedoe.com",
"profile_image_url": "https://pbs.twimg.com/profile_images/1460283925/jane_400x400.jpg",
"verified": true,
"url": "https://x.com/jane_builds"
},
"score": 86,
"score_reason": "Runs a paid trading community and asks how to collect payments for it.",
"hot": true,
"status": "in_conversation",
"persona": {
"id": "lvWBPnytpS",
"name": "Tipsters"
},
"campaign": {
"id": "c7RkQ2wLxe",
"name": "Tipsters warm intro",
"status": "in_progress"
},
"conversation_id": "msgOtJ8dvT",
"locked_to": null,
"found_at": "2026-11-05T14:02:11.482Z",
"scored_at": "2026-11-05T14:03:40.117Z",
"updated_at": "2026-11-05T14:03:40.117Z",
"metadata": {}
},
"conversation_id": "msgOtJ8dvT",
"campaign": {
"id": "c7RkQ2wLxe",
"name": "Tipsters warm intro",
"status": "in_progress"
},
"messages": [
"It takes two minutes. You set a price, members pay, and they get their Discord role right away.",
"Want the link to set it up?"
],
"sent_by": "auto",
"replying_to_message_id": "1985321409876543210",
"sent_at": "2026-11-06T08:14:05.000Z"
}
}sam.handoff
Sam AI hands a conversation to you, when it sees no good reply without you, the lead sent only a photo or a voice note, or its reply could not go out. In auto mode it then leaves the lead's next messages to you. Each handoff sends it once, if it is less than 6 hours old.
Data
- leadobjectThe lead to answer.
- conversation_idstringUse it with Retrieve a conversation for the whole thread.
- campaignobject or nullThe campaign the lead is in, with its
id,nameandstatus. - reasonstring or nullWhy Sam AI hands it to you, as it wrote it.
{
"id": "evt_8kQ2vXr4LmT9wPz1Hc6YbN3d",
"object": "event",
"type": "sam.handoff",
"created_at": "2026-11-05T14:03:40.117Z",
"account_id": "a9PzK2vRtM",
"data": {
"lead": {
"id": "msgOtJ8dvT",
"object": "lead",
"profile": {
"x_user_id": "1460283925",
"username": "jane_builds",
"name": "Jane Doe",
"bio": "Runs a paid trading community. Daily setups, no hype.",
"followers_count": 12400,
"following_count": 380,
"location": "Austin, TX",
"website": "https://janedoe.com",
"profile_image_url": "https://pbs.twimg.com/profile_images/1460283925/jane_400x400.jpg",
"verified": true,
"url": "https://x.com/jane_builds"
},
"score": 86,
"score_reason": "Runs a paid trading community and asks how to collect payments for it.",
"hot": true,
"status": "in_conversation",
"persona": {
"id": "lvWBPnytpS",
"name": "Tipsters"
},
"campaign": {
"id": "c7RkQ2wLxe",
"name": "Tipsters warm intro",
"status": "in_progress"
},
"conversation_id": "msgOtJ8dvT",
"locked_to": null,
"found_at": "2026-11-05T14:02:11.482Z",
"scored_at": "2026-11-05T14:03:40.117Z",
"updated_at": "2026-11-05T14:03:40.117Z",
"metadata": {}
},
"conversation_id": "msgOtJ8dvT",
"campaign": {
"id": "c7RkQ2wLxe",
"name": "Tipsters warm intro",
"status": "in_progress"
},
"reason": "She wants a call before she signs up."
}
}Delivery
Gemlead sends each event as a JSON POST and waits 10 seconds for your answer. Any 2xx counts as delivered. A redirect, any other status, a timeout or a connection error counts as a failure, and the event is retried.
| Header | Value |
|---|---|
Gemlead-Signature | The timestamp and the signature, t=1762444800,v1=5f2b... |
Gemlead-Event-Id | The event's id, to skip events you already handled. |
Gemlead-Event-Type | The event's type. |
User-Agent | Gemlead-Webhooks/1.0 |
POST /webhooks/gemlead HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: Gemlead-Webhooks/1.0
Gemlead-Event-Id: evt_8kQ2vXr4LmT9wPz1Hc6YbN3d
Gemlead-Event-Type: lead.hot
Gemlead-Signature: t=1762444800,v1=58ca5e964b92d617c979d3f067cdc2035a1a1a9d3f848d7d927c2db09b8e9baaVerify signatures
Anyone can send a request to your URL. Check that each one comes from Gemlead before you trust it.
- Read the raw body as bytes, before any JSON parsing. Parsing and writing it again changes the bytes.
- Split the
Gemlead-Signatureheader on commas.tis a Unix time in seconds,v1the signature. - Compute the HMAC SHA256 of
t, a dot and the raw body, keyed with the endpoint's whole secret,whsec_included, and write it in hex. - Compare it to each
v1in constant time. - Refuse a
tmore than 5 minutes away from your clock, so an old request cannot be replayed. Each retry is signed with a fresht.
Answer 400 when the check fails. Keep the secret in an environment variable like GEMLEAD_WEBHOOK_SECRET.
const crypto = require("crypto");
const express = require("express");
// rawBody: the request body exactly as received, before any JSON parsing.
function verifyGemleadSignature(rawBody, header, secret, toleranceSeconds = 300) {
if (typeof header !== "string") return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.split("=", 2).map((s) => (s || "").trim());
if (key === "t") timestamp = value;
if (key === "v1" && value) signatures.push(value);
}
if (!/^\d+$/.test(timestamp || "") || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
const app = express();
// The body must stay raw on this route.
app.post("/webhooks/gemlead", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyGemleadSignature(
req.body,
req.get("Gemlead-Signature"),
process.env.GEMLEAD_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send("Invalid signature");
const event = JSON.parse(req.body.toString("utf8"));
// Already handled? Skip it, retries send the same event.id again.
if (event.type === "lead.created") {
// event.data.object is the new lead, hot or not
} else if (event.type === "lead.hot") {
// event.data.object is the lead
} else if (event.type === "reply.received") {
// event.data.lead, event.data.message, event.data.campaign, event.data.conversation_id
}
res.sendStatus(200);
});
app.listen(3000);Retries
When a delivery fails, Gemlead tries again 7 times, each time after a longer wait. After the 8th attempt, about 45 hours after the first, the delivery is marked failed.
| Attempt | Sent |
|---|---|
| 1 | Right away |
| 2 | 1 minute after the previous failure |
| 3 | 5 minutes after the previous failure |
| 4 | 30 minutes after the previous failure |
| 5 | 2 hours after the previous failure |
| 6 | 6 hours after the previous failure |
| 7 | 12 hours after the previous failure |
| 8 | 24 hours after the previous failure |
An endpoint that fails for 3 days in a row, with at least 8 failed attempts, is disabled. Its status turns to disabled, its disabled_reason to failing, and the deliveries still waiting end as failed. Fix your server, then turn it back on in the app or with Update a webhook endpoint, which starts the count again. Test events are sent once and never count toward it.
List deliveries shows each attempt's status code and error, to see what your server answered.
Best practices
- Answer 2xx within 10 seconds, then do the work in a queue.
- Delivery is at least once. Use the event
id, also inGemlead-Event-Id, to skip an event you already handled. - Events can arrive out of order. Compare
created_at, or fetch the lead again for its latest state. - Verify every signature and refuse old timestamps.
- Rolling the secret replaces it at once. Deploy the new one right after, deliveries that fail meanwhile are retried and signed with it.
- Events and deliveries are kept 30 days.