API et webhooks
ScoutAI prévient vos autres outils de ce qui se passe (webhooks) et les laisse lui envoyer des entreprises ou lire les réponses (API publique). Les deux se configurent dans la page Intégrations, réservée aux propriétaires.
Webhooks
Un webhook est une adresse de votre système (CRM, n8n, Make, Zapier…) à laquelle ScoutAI envoie un POST en JSON à chaque événement.
- Dans « Intégrations », section Webhooks, saisissez l'adresse de réception. Elle doit être en
httpset joignable sur Internet : les adresses locales ou de réseau privé sont refusées. - Choisissez les événements à recevoir. Sans sélection, vous les recevez tous.
- Copiez le secret de signature (
whsec_…) affiché après la création. Il ne sera plus jamais montré : conservez-le dans votre outil. - Cliquez sur « Tester » : un événement
pingpart vers votre adresse et apparaît dans le journal des livraisons.
Événements
Chaque livraison a la même enveloppe :
{
"id": "3f6c0e1b2a8d4c7e9f0a1b2c3d4e5f60",
"event": "reply.received",
"createdAt": "2026-10-14T09:42:17.512Z",
"data": { … }
}
| Événement | Quand | Contenu de data |
|---|---|---|
message.sent | Un email est parti vers un prospect (premier email ou relance ; jamais un envoi de test) | prospectId, projectId, messageId, step (0 pour le premier email), contactEmail, companyName, sentAt |
reply.received | Une personne a répondu | prospectId, projectId, messageId, category (Interested, NotInterested ou Other), companyName, domain, contactEmail |
message.bounced | Un email n'a pas pu être remis | prospectId, projectId, messageId, kind (hard : adresse inexistante ; soft : échec temporaire), contactEmail |
prospect.unsubscribed | Un prospect s'est désinscrit | prospectId, projectId, via (link : lien de l'email ; reply : demande par réponse) |
domain.paused | Un domaine a été mis en pause par le garde-fou de délivrabilité | domainId, domain, reason, sent, bounced |
ping | Vous avez cliqué sur « Tester » | message |
Exemple de reply.received :
{
"id": "9b1d4c2e7f3a4b6c8d0e1f2a3b4c5d6e",
"event": "reply.received",
"createdAt": "2026-10-14T09:42:17.512Z",
"data": {
"prospectId": 1842,
"projectId": 12,
"messageId": 5127,
"category": "Interested",
"companyName": "Cave des Coteaux",
"domain": "cave-des-coteaux.be",
"contactEmail": "info@cave-des-coteaux.be"
}
}
Chaque requête porte trois en-têtes :
| En-tête | Contenu |
|---|---|
X-ScoutAI-Event | Le nom de l'événement |
X-ScoutAI-Delivery | L'identifiant de la livraison, le même à chaque nouvel essai : utilisez-le pour ignorer un doublon |
X-ScoutAI-Signature | t=<horodatage>,v1=<signature> |
Vérifier la signature
La signature prouve que la requête vient de ScoutAI et n'a pas été modifiée. v1 est le HMAC-SHA256, en hexadécimal, de la chaîne <t>.<corps brut de la requête>, avec votre secret comme clé. L'horodatage t (en secondes) fait partie du texte signé : refusez les livraisons trop anciennes pour vous protéger contre le rejeu.
Calculez la signature sur le corps brut, tel que reçu, et non sur un JSON relu puis réécrit : un seul espace de différence change la signature.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.SCOUTAI_WEBHOOK_SECRET; // whsec_…
app.post('/scoutai', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-ScoutAI-Signature') ?? '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const body = req.body.toString('utf8');
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${body}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; // 5 minutes
const valid = parts.v1?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!valid || !fresh) return res.sendStatus(401);
const event = JSON.parse(body);
// … traiter event.event et event.data
res.sendStatus(204);
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["SCOUTAI_WEBHOOK_SECRET"].encode()
@app.post("/scoutai")
def scoutai():
parts = dict(p.split("=", 1) for p in request.headers.get("X-ScoutAI-Signature", "").split(",") if "=" in p)
body = request.get_data(as_text=True)
expected = hmac.new(SECRET, f"{parts.get('t')}.{body}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(parts.get("v1", ""), expected) or abs(time.time() - int(parts.get("t", 0))) > 300:
abort(401)
event = request.get_json()
# … traiter event["event"] et event["data"]
return "", 204
Livraison et rejeu
- Une livraison réussit quand votre adresse répond avec un code 2xx en moins de 10 secondes. Les redirections ne sont pas suivies.
- Sinon, ScoutAI réessaie après 1 min, 5 min, 30 min, 2 h puis 12 h. Après ces cinq nouveaux essais, la livraison passe en échec.
- Le Journal des livraisons montre chaque livraison : événement, adresse, statut, nombre d'essais, code HTTP reçu et erreur. Rejouer renvoie une livraison réussie ou en échec, avec le même identifiant.
- Un événement est enregistré en même temps que le fait qui le déclenche : il n'est pas perdu si votre système est indisponible un moment.
Répondez vite (204), puis traitez l'événement en arrière-plan. Et rendez votre traitement idempotent avec X-ScoutAI-Delivery : un rejeu ne doit pas créer deux fois la même tâche dans votre CRM.
Clés d'API
Dans Intégrations, section Clés d'API, donnez un nom à la clé et choisissez son accès :
| Accès | Permet |
|---|---|
| Lecture | Lire les prospects et les réponses |
| Lecture et envoi d'entreprises | Lire, et ajouter des entreprises à une campagne |
La clé (sk_…) n'est affichée qu'une fois : seule son empreinte est conservée. Elle n'ouvre que l'API publique /api/v1, jamais le reste du portail. Révoquez-la à tout moment ; elle cesse alors immédiatement de fonctionner.
Envoyez-la dans l'en-tête X-API-KEY de chaque requête :
curl https://VOTRE-INSTANCE/api/v1/prospects \
-H "X-API-KEY: sk_votre_cle"
API publique v1
Les requêtes et réponses sont en JSON. Les statuts et catégories s'écrivent en camelCase (inSequence, notInterested). Les listes sont paginées : page (à partir de 1) et pageSize (25 par défaut, 100 au plus), et la réponse contient items, total, page et pageSize.
Lister les prospects
GET /api/v1/prospects — accès Lecture.
| Paramètre | Description |
|---|---|
projectId | Limiter à un projet |
campaignId | Limiter à une campagne |
status | sourced, enriched, enrichFailed, disqualified, inReview, approved, rejected, inSequence, replied, suppressed, bounced |
curl "https://VOTRE-INSTANCE/api/v1/prospects?status=replied&pageSize=50" \
-H "X-API-KEY: sk_votre_cle"
{
"items": [
{
"id": 1842,
"projectId": 12,
"campaignId": 34,
"status": "replied",
"companyName": "Cave des Coteaux",
"domain": "cave-des-coteaux.be",
"website": "https://www.cave-des-coteaux.be",
"phone": "+32 81 00 00 00",
"address": "Rue du Pont 12",
"postalCode": "5000",
"city": "Namur",
"language": "fr",
"contactEmail": "info@cave-des-coteaux.be",
"candidateEmails": ["info@cave-des-coteaux.be"],
"failureReason": null,
"createdAt": "2026-10-10T08:12:44Z"
}
],
"total": 1,
"page": 1,
"pageSize": 50
}
Ajouter des entreprises à une campagne
POST /api/v1/projects/{projectId}/campaigns/{campaignId}/prospects — accès Lecture et envoi d'entreprises.
Envoyez de 1 à 200 entreprises par requête. Elles suivent exactement le même chemin qu'une collecte : dédoublonnage, liste de suppression, puis lecture du site, rédaction et file de revue. Seul name est obligatoire, mais une entreprise sans site web ni email sur son propre domaine est rejetée.
curl -X POST "https://VOTRE-INSTANCE/api/v1/projects/12/campaigns/34/prospects" \
-H "X-API-KEY: sk_votre_cle" \
-H "Content-Type: application/json" \
-d '{
"leads": [
{
"name": "Cave des Coteaux",
"website": "https://www.cave-des-coteaux.be",
"email": "info@cave-des-coteaux.be",
"phone": "+32 81 00 00 00",
"address": "Rue du Pont 12",
"postalCode": "5000",
"city": "Namur",
"enterpriseNumber": "0123.456.789"
}
]
}'
{
"received": 1,
"created": 1,
"duplicates": 0,
"rejected": 0,
"rejections": []
}
Les motifs de rejet possibles sont no_name, no_website et suppressed. Un doublon (entreprise déjà présente dans le projet) n'est pas une erreur : il est compté dans duplicates.
Lister les réponses
GET /api/v1/replies — accès Lecture.
| Paramètre | Description |
|---|---|
since | Uniquement les réponses reçues depuis cette date (ISO 8601, par exemple 2026-10-14T00:00:00Z) |
category | interested, notInterested, unsubscribe, outOfOffice, bounceHard, bounceSoft, other |
curl "https://VOTRE-INSTANCE/api/v1/replies?category=interested&since=2026-10-14T00:00:00Z" \
-H "X-API-KEY: sk_votre_cle"
Chaque réponse contient id, prospectId, projectId, companyName, contactEmail, fromEmail, subject, body (le texte reçu, tronqué), receivedAt, category et classifiedBy (rule, model ou person).
Erreurs
Une erreur renvoie un code HTTP et un corps JSON :
{
"message": "The campaign does not belong to the project",
"code": "campaign_not_in_project",
"args": {}
}
message est une explication en anglais ; code est stable et sert à traiter l'erreur dans votre programme.
Les erreurs de format détectées avant tout traitement (champ obligatoire absent, texte trop long, liste vide ou de plus de 200 entreprises) suivent le format standard application/problem+json, avec le détail par champ dans errors.
| Code HTTP | Signification |
|---|---|
| 400 | Requête invalide (champ manquant, plus de 200 entreprises, campagne d'un autre projet…) |
| 401 | Clé absente, inconnue ou révoquée |
| 403 | La clé n'a pas l'accès nécessaire (par exemple une clé en lecture qui envoie des entreprises) |
| 404 | Projet ou campagne introuvable. Une ressource d'une autre organisation est toujours « introuvable ». |