Webhooks
Webhooks
Gemlead envoie une requête HTTPS à ton serveur dès qu'un lead est trouvé, devient chaud, répond ou réagit à ton message, ou dès que Sam AI a une réponse prête, en envoie une ou te passe une conversation. Elle est signée, pour que tu saches qu'elle vient de nous.
Configurer un endpoint
Ajoute un endpoint dans l'app, sur la page API, ou avec Créer un endpoint webhook. Donne-lui une URL HTTPS publique et les types d'événements voulus, et garde le secret qu'il renvoie pour vérifier les signatures.
- HTTPS uniquement, pas d'identifiants dans l'URL, et aucune adresse privée, loopback ou link-local.
- Jusqu'à 10 endpoints par compte.
filters.persona_idslimite les événements qu'un endpoint reçoit aux leads de certains personas, quel que soit leur type.- Vérifie ton serveur avec Envoyer un événement test avant l'arrivée des vrais événements.
Les webhooks sont inclus dans les plans Business et Enterprise. Si le compte passe au plan Free ou Starter, ses endpoints sont conservés mais n'envoient rien tant qu'il n'est pas revenu en Business ou 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"]
}
}'Types d'événements
Tous les événements ont la même enveloppe. Leur data dépend de leur type.
| Type | Envoyé quand |
|---|---|
lead.created | Gemlead trouve et score un nouveau lead, quel que soit le résultat. L'événement part une seule fois par lead, avec son premier score, et seulement dans les 3 jours qui suivent sa découverte, donc un rescore d'un lead plus ancien ne l'envoie jamais. Un lead qui obtient 60 ou plus dès le départ envoie aussi lead.hot. |
lead.hot | Le score d'un lead atteint 60 ou plus pour la première fois. L'événement part une seule fois par lead, et un rescore ne le renvoie jamais. |
reply.received | Un lead t'envoie un nouveau message, en campagne ou non. L'événement part une fois par message de moins de 6 heures. Quand beaucoup de messages du lead arrivent d'un coup, seuls les 5 plus récents le déclenchent. |
reaction.received | Un lead réagit à un de tes messages avec un emoji. L'événement part une fois par emoji du lead sur un message. |
sam.suggestion.ready | Sam AI a une nouvelle réponse prête à envoyer, dans une conversation en mode draft. L'événement part une fois par suggestion de moins de 6 heures. |
sam.reply.sent | Une réponse de Sam AI part de ton compte X, toute seule en mode auto ou une fois que tu l'as validée dans l'app. L'événement part une fois par réponse de moins de 6 heures. Les messages que tu écris toi-même ne le déclenchent jamais, ils apparaissent dans Récupérer une conversation. |
sam.handoff | Sam AI te passe une conversation quand il ne voit pas de bonne réponse sans toi, que le lead n'a envoyé qu'une photo ou un message vocal, ou que sa réponse n'a pas pu partir. En mode auto, il te laisse ensuite les prochains messages du lead. L'événement part une fois par passage de main, s'il date de moins de 6 heures. |
L'objet événement
- idstringIdentifiant unique de l'événement,
evt_suivi de 24 caractères. Aussi dans le headerGemlead-Event-Id. - objectstringToujours
event. - typestringLe type d'événement.
lead.createdlead.hotreply.receivedreaction.receivedsam.suggestion.readysam.reply.sentsam.handoff - created_atstringDate de l'événement, en ISO 8601.
- account_idstringLe compte Gemlead concerné.
- dataobjectLe contenu de l'événement, qui dépend de son type.
lead.created
Gemlead trouve et score un nouveau lead, quel que soit le résultat. L'événement part une seule fois par lead, avec son premier score, et seulement dans les 3 jours qui suivent sa découverte, donc un rescore d'un lead plus ancien ne l'envoie jamais. Un lead qui obtient 60 ou plus dès le départ envoie aussi lead.hot.
Données
- objectobjectLe lead, tel que Récupérer un lead le renvoie.
{
"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
Le score d'un lead atteint 60 ou plus pour la première fois. L'événement part une seule fois par lead, et un rescore ne le renvoie jamais.
Données
- objectobjectLe lead, tel que Récupérer un lead le renvoie.
{
"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
Un lead t'envoie un nouveau message, en campagne ou non. L'événement part une fois par message de moins de 6 heures. Quand beaucoup de messages du lead arrivent d'un coup, seuls les 5 plus récents le déclenchent.
Données
- leadobjectLe lead qui a écrit.
- messageobject
- campaignobject ou nullLa campagne du lead, avec son
id, sonnameet sonstatus. - conversation_idstringUtilise-le avec Récupérer une conversation pour avoir tout le fil.
{
"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
Un lead réagit à un de tes messages avec un emoji. L'événement part une fois par emoji du lead sur un message.
{
"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 a une nouvelle réponse prête à envoyer, dans une conversation en mode draft. L'événement part une fois par suggestion de moins de 6 heures.
Données
- leadobjectLe lead auquel Sam AI répond.
- conversation_idstringUtilise-le avec Récupérer le Sam AI d'une conversation pour relire la suggestion ou changer le mode.
- campaignobject ou nullLa campagne du lead, avec son
id, sonnameet sonstatus. - suggestionobjectLa réponse que Sam AI a prête.
{
"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
Une réponse de Sam AI part de ton compte X, toute seule en mode auto ou une fois que tu l'as validée dans l'app. L'événement part une fois par réponse de moins de 6 heures. Les messages que tu écris toi-même ne le déclenchent jamais, ils apparaissent dans Récupérer une conversation.
Données
- leadobjectLe lead auquel Sam AI a répondu.
- conversation_idstringUtilise-le avec Récupérer une conversation pour avoir tout le fil.
- campaignobject ou nullLa campagne du lead, avec son
id, sonnameet sonstatus. - messagestableau de stringsLes DM envoyés par Sam AI, dans l'ordre.
- sent_bystring
autoquand Sam AI l'a envoyée tout seul,approvedquand tu l'as validée dans l'app.autoapproved - replying_to_message_idstring ou nullLe message du lead auquel elle a répondu.
- sent_atstringDate d'envoi, en 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 te passe une conversation quand il ne voit pas de bonne réponse sans toi, que le lead n'a envoyé qu'une photo ou un message vocal, ou que sa réponse n'a pas pu partir. En mode auto, il te laisse ensuite les prochains messages du lead. L'événement part une fois par passage de main, s'il date de moins de 6 heures.
Données
- leadobjectLe lead à qui répondre.
- conversation_idstringUtilise-le avec Récupérer une conversation pour avoir tout le fil.
- campaignobject ou nullLa campagne du lead, avec son
id, sonnameet sonstatus. - reasonstring ou nullLa raison pour laquelle Sam AI te la passe, telle qu'il l'a écrite.
{
"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."
}
}Envoi
Gemlead envoie chaque événement en POST JSON et attend ta réponse pendant 10 secondes. Toute réponse 2xx compte comme un envoi réussi. Une redirection, tout autre statut, un timeout ou une erreur de connexion compte comme un échec, et l'événement est renvoyé.
| Header | Valeur |
|---|---|
Gemlead-Signature | Le timestamp et la signature, t=1762444800,v1=5f2b... |
Gemlead-Event-Id | L'id de l'événement, pour ignorer ceux que tu as déjà traités. |
Gemlead-Event-Type | Le type de l'événement. |
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=58ca5e964b92d617c979d3f067cdc2035a1a1a9d3f848d7d927c2db09b8e9baaVérifier les signatures
N'importe qui peut envoyer une requête à ton URL. Vérifie que chacune vient bien de Gemlead avant de lui faire confiance.
- Lis le corps brut en octets, avant tout parsing JSON. Le parser puis le resérialiser modifie les octets.
- Découpe le header
Gemlead-Signaturesur les virgules.test un timestamp Unix en secondes,v1la signature. - Calcule le HMAC SHA256 de
t, d'un point et du corps brut, avec le secret complet de l'endpoint comme clé,whsec_compris, et écris-le en hexadécimal. - Compare-le à chaque
v1en temps constant. - Refuse un
tà plus de 5 minutes de ton horloge, pour qu'une vieille requête ne puisse pas être rejouée. Chaque nouvelle tentative est signée avec un nouveaut.
Réponds 400 quand la vérification échoue. Garde le secret dans une variable d'environnement comme 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);Nouvelles tentatives
Quand un envoi échoue, Gemlead réessaie 7 fois, en attendant plus longtemps à chaque fois. Après la 8e tentative, environ 45 heures après la première, l'envoi est marqué en échec.
| Tentative | Envoyée |
|---|---|
| 1 | Tout de suite |
| 2 | 1 minute après l'échec précédent |
| 3 | 5 minutes après l'échec précédent |
| 4 | 30 minutes après l'échec précédent |
| 5 | 2 heures après l'échec précédent |
| 6 | 6 heures après l'échec précédent |
| 7 | 12 heures après l'échec précédent |
| 8 | 24 heures après l'échec précédent |
Un endpoint qui échoue 3 jours de suite, avec au moins 8 tentatives ratées, est désactivé. Son status passe à disabled, son disabled_reason à failing, et les envois encore en attente finissent en échec. Corrige ton serveur, puis réactive-le dans l'app ou avec Modifier un endpoint webhook, ce qui remet le compteur à zéro. Les événements test sont envoyés une seule fois et ne comptent jamais.
Lister les envois montre le code de statut et l'erreur de chaque tentative, pour voir ce que ton serveur a répondu.
Bonnes pratiques
- Réponds par un 2xx en moins de 10 secondes, puis traite l'événement dans une file d'attente.
- Chaque événement arrive au moins une fois. Utilise son
id, aussi dansGemlead-Event-Id, pour ignorer un événement déjà traité. - Les événements peuvent arriver dans le désordre. Compare
created_at, ou récupère à nouveau le lead pour avoir son dernier état. - Vérifie chaque signature et refuse les vieux timestamps.
- Quand tu renouvelles le secret, il est remplacé aussitôt. Déploie le nouveau juste après. Les envois qui échouent entre-temps sont renvoyés et signés avec lui.
- Les événements et les envois sont conservés 30 jours.