Webhooks Public API — /api/public/v1/webhooks
StormeoOS pousse des notifications HTTP vers votre URL à chaque mutation des ressources de votre agence.
Source : server/publicApi/webhookDispatcher.ts + server/publicApi/webhookEmitter.ts.
Souscrire
Voir ../public-api/endpoints/webhooks.md — endpoints POST /api/public/v1/webhooks.
À la création, le serveur génère un signingSecret (32 bytes random hex) affiché une seule fois. À stocker immédiatement dans un secret manager.
Format du payload
Toutes les requêtes sortantes :
- Méthode :
POST - Content-Type :
application/json - User-Agent :
StormeoOS-Webhooks/1.0 - Body : JSON UTF-8 défini ci-dessous
{
"id": 12345,
"event": "client.created",
"agencyId": 7,
"occurredAt": "2026-04-27T10:00:00.000Z",
"data": {
"client": {
"id": 42,
"name": "Acme SARL",
"email": "contact@acme.fr",
"createdAt": "2026-04-27T10:00:00.000Z"
}
}
}| Champ | Type | Description |
|---|---|---|
id | int | ID interne de la livraison (utilisable pour dédup) |
event | string | Type d'event (<resource>.<action>) |
agencyId | int | Agence concernée |
occurredAt | ISO date | Timestamp UTC de la création de l'event |
data | object | Payload spécifique à l'event |
Headers
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | StormeoOS-Webhooks/1.0 |
X-Stormeo-Event | Event type (ex: client.created) — utile pour switch côté handler |
X-Stormeo-Signature | HMAC-SHA256 du body, en hex (sans préfixe sha256=) |
X-Stormeo-Delivery-Id | ID interne, pour idempotence côté receveur |
X-Stormeo-Attempt | Numéro de tentative (1, 2, 3, …) |
Vérification de signature
L'algorithme :
signature = HMAC-SHA256(
key = signingSecret_de_la_souscription,
data = body_brut
)⚠️ Différent du HMAC plugins : pas de timestamp dans la signature. Juste
HMAC(secret, body).
Vérification — Node.js
const crypto = require('crypto');
function verifyStormeoWebhook(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
if (expected.length !== signatureHeader.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signatureHeader, 'hex')
);
}
// Express handler
app.post('/stormeo/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-stormeo-signature'];
if (!verifyStormeoWebhook(req.body, sig, process.env.STORMEO_WEBHOOK_SECRET)) {
return res.status(401).send('invalid signature');
}
const payload = JSON.parse(req.body.toString());
// ... traiter payload.event
res.status(200).send('ok');
});Vérification — PHP
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_STORMEO_SIGNATURE'] ?? '';
$secret = getenv('STORMEO_WEBHOOK_SECRET');
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('invalid signature');
}
$payload = json_decode($rawBody, true);
// ...
http_response_code(200);
echo 'ok';Vérification — Python (Flask)
import hmac, hashlib, os
from flask import request, abort
@app.route("/stormeo/webhook", methods=["POST"])
def stormeo_webhook():
raw = request.get_data()
sig = request.headers.get("X-Stormeo-Signature", "")
secret = os.environ["STORMEO_WEBHOOK_SECRET"]
expected = hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(401)
payload = request.get_json()
# ...
return "ok", 200Politique de retry
| Tentative | Délai après l'échec |
|---|---|
| 1 | immédiat |
| 2 | 60s × 2¹ = 120s |
| 3 | 60s × 2² = 240s |
| 4 | 60s × 2³ = 480s |
| 5 | 60s × 2⁴ = 960s |
| 6 | 60s × 2⁵ = 1920s |
| 7+ | capé à 3600s (1h) |
Backoff exponentiel capé à 1h. Source : server/publicApi/webhookDispatcher.ts:18-21.
maxRetries est configurable par souscription (défaut 5, max 20). Au-delà, la livraison passe en status='dead'.
Auto-disable
Si une souscription accumule 20 échecs consécutifs, elle est auto-désactivée (isActive=false). Réactivation manuelle via PATCH /webhooks/:id { isActive: true } (le failureCount repart à 0).
Idempotence côté receveur
StormeoOS peut envoyer le même event 2 fois en cas de timeout réseau (le receveur a traité mais le 2xx n'est pas remonté). Mandatoire de dédupliquer :
const seenDeliveryIds = new Set(); // ou Redis SETEX 24h
if (seenDeliveryIds.has(req.headers['x-stormeo-delivery-id'])) {
return res.status(200).send('already processed');
}
seenDeliveryIds.add(req.headers['x-stormeo-delivery-id']);
// ... traiterQuoi répondre
| Statut HTTP | Comportement StormeoOS |
|---|---|
2xx | Succès, livraison marquée success, failureCount=0 |
3xx | Pas suivi — considéré comme échec |
4xx | Échec, retry selon backoff |
5xx | Échec, retry selon backoff |
| Timeout (>10s) | Échec (Timeout after 10000ms) |
| Connexion refusée | Échec (ECONNREFUSED) |
Recommandation : répondre 200 OK dès la signature validée et le payload mis en queue interne. Reporter le traitement lourd dans un worker (sinon vous risquez le timeout 10s).
Catalogue d'événements
Liste complète exposée par GET /api/public/v1/webhooks-events ou dans server/publicApi/webhookEmitter.ts:50-59.
CRM
| Event | Payload data |
|---|---|
client.created | { client: <Client> } |
client.updated | { client: <Client> } |
client.deleted | { id: 42 } |
contact.created | { contact: <Contact> } |
contact.updated | { contact: <Contact> } |
contact.deleted | { id: 42 } |
website.created | { website: <Website> } |
website.updated | { website: <Website> } |
website.deleted | { id: 42 } |
Tickets
| Event | Payload |
|---|---|
ticket.created | { ticket: <Ticket> } |
ticket.updated | { ticket: <Ticket> } |
ticket.deleted | { id: 42 } |
ticket.status_changed | { ticket: <Ticket>, previousStatus: "open", newStatus: "in_progress" } |
Facturation
| Event | Payload |
|---|---|
invoice.created | { invoice: <Invoice> } |
invoice.updated | { invoice: <Invoice> } |
invoice.paid | { invoice: <Invoice> } (status passé à paid) |
invoice.sent | { invoice: <Invoice> } (status passé à sent) |
quote.created | { quote: <Quote> } |
quote.updated | { quote: <Quote> } |
quote.accepted | { quote: <Quote> } (status passé à accepted) |
Planning
| Event | Payload |
|---|---|
task.created, task.updated, task.deleted | { task: <Task> } ou { id: 42 } |
task.completed | { task: <Task> } (status done/completed) |
event.created, event.updated, event.deleted | { event: <Event> } ou { id: 42 } |
Système
| Event | Payload |
|---|---|
test.ping | { message, timestamp, subscriptionId } (déclenché par POST /webhooks/:id/test) |
Tronquage des payloads
Si le payload de l'event dépasse 64 KB sérialisé, il est tronqué et remplacé par :
{
"_truncated": true,
"_originalSize": 92345,
"_hint": "Payload too large. Use the API to fetch the full resource.",
"id": 42
}Solution : faire un GET /api/public/v1/<ressource>/:id dans votre handler avec l'ID fourni.
Source : server/publicApi/webhookEmitter.ts:62-78.
Tester votre endpoint
Voir ../public-api/endpoints/webhooks.md — POST /api/public/v1/webhooks/:id/test envoie un test.ping immédiatement.
Outils utiles pour le développement
- smee.io ou ngrok : tunneliser votre localhost vers une URL publique pour tests
- webhook.site : URL temporaire pour inspecter le payload sans coder le receveur
curl: reproduire la signature manuellement avecopenssl dgst -sha256 -hmac <secret>