Skip to content

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
json
{
  "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"
    }
  }
}
ChampTypeDescription
idintID interne de la livraison (utilisable pour dédup)
eventstringType d'event (<resource>.<action>)
agencyIdintAgence concernée
occurredAtISO dateTimestamp UTC de la création de l'event
dataobjectPayload spécifique à l'event

Headers ​

HeaderDescription
Content-Typeapplication/json
User-AgentStormeoOS-Webhooks/1.0
X-Stormeo-EventEvent type (ex: client.created) — utile pour switch côté handler
X-Stormeo-SignatureHMAC-SHA256 du body, en hex (sans préfixe sha256=)
X-Stormeo-Delivery-IdID interne, pour idempotence côté receveur
X-Stormeo-AttemptNumé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 ​

javascript
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 ​

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) ​

python
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", 200

Politique de retry ​

TentativeDélai après l'échec
1immédiat
260s × 2¹ = 120s
360s × 2² = 240s
460s × 2³ = 480s
560s × 2⁴ = 960s
660s × 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 :

javascript
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']);
// ... traiter

Quoi répondre ​

Statut HTTPComportement StormeoOS
2xxSuccès, livraison marquée success, failureCount=0
3xxPas 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 ​

EventPayload 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 ​

EventPayload
ticket.created{ ticket: <Ticket> }
ticket.updated{ ticket: <Ticket> }
ticket.deleted{ id: 42 }
ticket.status_changed{ ticket: <Ticket>, previousStatus: "open", newStatus: "in_progress" }

Facturation ​

EventPayload
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 ​

EventPayload
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 ​

EventPayload
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 :

json
{
  "_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 avec openssl dgst -sha256 -hmac <secret>

StormeoOS API