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.mdPOST /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