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.

  1. Dans « Intégrations », section Webhooks, saisissez l'adresse de réception. Elle doit être en https et joignable sur Internet : les adresses locales ou de réseau privé sont refusées.
  2. Choisissez les événements à recevoir. Sans sélection, vous les recevez tous.
  3. Copiez le secret de signature (whsec_…) affiché après la création. Il ne sera plus jamais montré : conservez-le dans votre outil.
  4. Cliquez sur « Tester » : un événement ping part 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énementQuandContenu de data
message.sentUn 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.receivedUne personne a réponduprospectId, projectId, messageId, category (Interested, NotInterested ou Other), companyName, domain, contactEmail
message.bouncedUn email n'a pas pu être remisprospectId, projectId, messageId, kind (hard : adresse inexistante ; soft : échec temporaire), contactEmail
prospect.unsubscribedUn prospect s'est désinscritprospectId, projectId, via (link : lien de l'email ; reply : demande par réponse)
domain.pausedUn domaine a été mis en pause par le garde-fou de délivrabilitédomainId, domain, reason, sent, bounced
pingVous 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êteContenu
X-ScoutAI-EventLe nom de l'événement
X-ScoutAI-DeliveryL'identifiant de la livraison, le même à chaque nouvel essai : utilisez-le pour ignorer un doublon
X-ScoutAI-Signaturet=<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

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èsPermet
LectureLire les prospects et les réponses
Lecture et envoi d'entreprisesLire, 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ètreDescription
projectIdLimiter à un projet
campaignIdLimiter à une campagne
statussourced, 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ètreDescription
sinceUniquement les réponses reçues depuis cette date (ISO 8601, par exemple 2026-10-14T00:00:00Z)
categoryinterested, 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 HTTPSignification
400Requête invalide (champ manquant, plus de 200 entreprises, campagne d'un autre projet…)
401Clé absente, inconnue ou révoquée
403La clé n'a pas l'accès nécessaire (par exemple une clé en lecture qui envoie des entreprises)
404Projet ou campagne introuvable. Une ressource d'une autre organisation est toujours « introuvable ».