Wocart

Webhooks

Un webhook envoie les Business Events de votre boutique à un endpoint de votre propre serveur au moment où ils se produisent : une commande passée, confirmée, remise à un transporteur, livrée. Chaque événement est envoyé en POST au format JSON, signé selon la spécification Standard Webhooks, et renvoyé jusqu'à ce que votre endpoint l'accepte.

Les transporteurs qui signalent des livraisons à votre boutique, c'est l'autre sens : lisez Webhooks des transporteurs.

Ajouter un webhook

Dans le tableau de bord, ouvrez Intégrations dans le menu latéral et choisissez Ajouter un webhook ; donnez-lui un nom, l'URL de votre endpoint et les événements qu'il reçoit. Par l'API, POST /api/event-consumers avec le scope integrations:manage (integrations:read liste les webhooks et leurs livraisons) :

TerminalCode
curl -X POST http://localhost:8787/api/event-consumers \ -H "X-API-Key: cod_your_api_key_here" \ -H "Content-Type: application/json" \ -d '{"kind":"webhook","name":"Order system","url":"https://erp.example.dz/hooks/wocart","subscription":["order.*","shipment.delivered"]}'

La réponse contient le webhook et son secret de signature, whsec_…. Il n'est affiché qu'une fois : gardez-le dans les secrets de votre serveur. Toutes les réponses suivantes l'affichent masqué.

  • L'URL doit être en https, publique sur Internet (aucune adresse privée, locale ou réservée), sans nom d'utilisateur ni mot de passe.
  • Une boutique a au plus 10 webhooks.
  • Un webhook reçoit les événements enregistrés après son ajout. Pour lui envoyer des événements plus anciens, utilisez un Replay (voir plus bas).

Un abonnement nomme des types d'événements exactement (order.confirmed), par sujet (order.*, shipment.*), ou tous (*). Un sujet ou * prend aussi les types ajoutés plus tard au catalogue.

Ce que reçoit votre endpoint

Chaque livraison est un POST vers votre URL :

En-têteValeur
content-typeapplication/json
user-agentCodFlow-Webhooks/1
webhook-idL'id de l'événement. Le même à chaque tentative et à chaque Replay.
webhook-timestampL'heure d'envoi de cette tentative, en secondes Unix.
webhook-signaturev1,<signature>, une par secret de signature, séparées par des espaces.

Le corps est le Business Event, exactement tel que le renvoie l'API du journal d'événements :

JSONCode
{ "id": "0f9e8d7c-6b5a-4f3e-8d2c-1b0a09080702", "sequence": 1042, "type": "order.confirmed", "version": 1, "subject": { "type": "order", "id": "7f3c2a9e-4b1d-4e8a-9c55-2d6f0b8e1a47" }, "orderId": "7f3c2a9e-4b1d-4e8a-9c55-2d6f0b8e1a47", "occurredAt": "2026-10-25T09:40:55.000Z", "recordedAt": "2026-10-25T09:40:55.480Z", "actor": { "kind": "staff", "id": "1d3f5a7c-9e1b-4d3f-a5c7-9e1b3d5f7a9c", "name": "Samira" }, "origin": "dashboard", "data": { "order": { "id": "7f3c2a9e-4b1d-4e8a-9c55-2d6f0b8e1a47", "number": "ORD-20261025-0042", "status": "confirmed", "type": "online", "placedAt": "2026-10-25T09:14:03.120Z", "locale": "ar", "customer": { "id": "5a0e7c13-8f2b-4d9e-a6c4-1b3d5f7e9a20", "name": "Amina Benali", "phone": "0551234567", "email": null }, "destination": { "wilaya": { "id": 16, "name": "Alger", "nameAr": "الجزائر" }, "commune": { "id": "16001", "name": "Alger Centre", "nameAr": "الجزائر الوسطى" }, "city": null, "address": "12 rue Didouche Mourad", "deliveryType": "home", "stationCode": null }, "lines": [ { "id": "b2d4f6a8-1c3e-4a5b-8d7f-9e0a1b2c3d4e", "productId": "e8a1c3b5-7d9f-4b2a-a4c6-8e0f2a4c6e8a", "variantId": "f1b3d5c7-9e1a-4c3b-b5d7-9f1b3d5f7a9c", "name": "Robe kabyle", "variantLabel": "Rouge / M", "sku": "RK-R-M", "quantity": 2, "unitPrice": { "amount": "4500.00", "currency": "DZD" }, "discount": { "amount": "500.00", "currency": "DZD" }, "total": { "amount": "8500.00", "currency": "DZD" }, "gift": false, "offer": { "id": "a7c9e1b3-5d7f-4a9c-8e1b-3d5f7a9c1e3b", "name": "La deuxième à -500 DA" }, "returnedQuantity": 0 } ], "subtotal": { "amount": "8500.00", "currency": "DZD" }, "deliveryFee": { "amount": "500.00", "currency": "DZD" }, "total": { "amount": "9000.00", "currency": "DZD" }, "coupon": null, "freeDeliveryOffer": null, "notes": "Appeler avant de passer", "customFields": [ { "id": "c3e5a7b9-1d3f-4b5a-9c7e-1f3a5b7d9e1f", "label": "Taille habituelle", "type": "select", "value": "M" } ] }, "previousStatus": "new" } }
ChampSens
idL'id unique de l'événement. Reconnaissez par lui un événement déjà traité.
sequenceSa position dans le journal d'événements de la boutique : un événement plus récent a un numéro plus grand.
type, versionLe type d'événement et la version de ses data.
subjectCe dont parle l'événement : une commande ou une expédition.
orderIdLa commande concernée.
occurredAtQuand il s'est produit : un transporteur peut signaler une livraison des heures plus tard.
recordedAtQuand la boutique l'a enregistré.
actor, originQui l'a causé (équipe, agent IA, client, transporteur, système) et par où (tableau de bord, API, vitrine, webhook transporteur…).
dataLa commande, et l'expédition pour un événement de livraison, telles qu'elles étaient à ce moment-là.

Les montants sont des chaînes décimales avec leur devise : { "amount": "9000.00", "currency": "DZD" }. Un événement ne dépasse jamais 64 Ko.

Vérifier la signature

Vérifiez chaque requête avant d'agir. La signature est le HMAC-SHA256 en base64 de <webhook-id>.<webhook-timestamp>.<corps brut>, avec pour clé les octets que code le secret de signature : la partie après whsec_, décodée en base64.

  1. Lisez le corps brut, tel qu'il est arrivé. Analyser puis réécrire le JSON change les octets, et la signature ne correspond plus.
  2. Refusez un webhook-timestamp à plus de cinq minutes de votre horloge, pour qu'une requête interceptée ne puisse pas être rejouée plus tard.
  3. Acceptez la requête quand l'une des signatures v1, séparées par des espaces correspond, comparée en temps constant.

Node.js

JavascriptCode
import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 5 * 60; /** Whether a request came from your store, signed with this Signing Secret. */ export function verifyWebhook(secret, headers, rawBody) { const id = headers["webhook-id"]; const timestamp = Number(headers["webhook-timestamp"]); const signatures = headers["webhook-signature"]; if (!id || !signatures || !Number.isInteger(timestamp)) return false; if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false; const key = Buffer.from(secret.slice("whsec_".length), "base64"); const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest(); return signatures.split(" ").some((entry) => { const [version, signature = ""] = entry.split(","); const given = Buffer.from(signature, "base64"); return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected); }); }

Passez le corps brut sous forme de chaîne, par exemple avec express.text({ type: "application/json" }).

PHP

PHPCode
<?php const TOLERANCE_SECONDS = 5 * 60; /** Whether a request came from your store, signed with this Signing Secret. */ function verify_webhook(string $secret, array $headers, string $rawBody): bool { $id = $headers['webhook-id'] ?? ''; $timestamp = $headers['webhook-timestamp'] ?? ''; $signatures = $headers['webhook-signature'] ?? ''; if ($id === '' || $signatures === '' || !ctype_digit($timestamp)) return false; if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) return false; $key = base64_decode(substr($secret, strlen('whsec_')), true); if ($key === false) return false; $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true)); foreach (explode(' ', $signatures) as $entry) { [$version, $signature] = array_pad(explode(',', $entry, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $signature)) return true; } return false; }

Le corps brut est file_get_contents('php://input') (dans Laravel, $request->getContent()) ; donnez les en-têtes avec des noms en minuscules.

Python

Code
import base64 import hashlib import hmac import time TOLERANCE_SECONDS = 5 * 60 def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> bool: """Whether a request came from your store, signed with this Signing Secret.""" msg_id = headers.get("webhook-id", "") timestamp = headers.get("webhook-timestamp", "") signatures = headers.get("webhook-signature", "") if not msg_id or not signatures or not timestamp.isdigit(): return False if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() return any( version == "v1" and hmac.compare_digest(expected, signature) for version, _, signature in (entry.partition(",") for entry in signatures.split(" ")) )

Le corps brut est request.get_data() avec Flask, ou await request.body() avec FastAPI.

Toute bibliothèque Standard Webhooks vérifie aussi ces requêtes, par exemple new Webhook(secret).verify(rawBody, headers) avec le paquet standardwebhooks.

Changer le secret de signature

Changer le secret de signature dans le tableau de bord, ou POST /api/event-consumers/{id}/rotate-secret, renvoie une seule fois un nouveau secret. Pendant 24 heures, chaque requête porte une signature par secret : passez votre endpoint au nouveau dans ce délai, et rien n'est refusé. Ensuite, seul le nouveau secret signe.

Répondre vite

Votre réponse décide de la suite :

Votre endpoint répondCe que fait la boutique
Tout 2xx, en moins de 15 secondesLivré.
410 GoneArrête ce webhook et ne lui envoie plus rien tant que vous ne le redémarrez pas.
Autre chose : un autre statut, une redirection, pas de réponse à temps, pas de connexionRéessaie plus tard, selon le calendrier ci-dessous.

Répondez 2xx dès que l'événement est stocké, puis faites le travail. Les redirections ne sont pas suivies : donnez l'URL finale. Un en-tête Retry-After sur un refus est respecté, jusqu'à un jour.

Nouvelles tentatives, doublons et ordre

Une livraison est tentée 8 fois. La première tentative part quelques secondes après l'événement ; chaque échec attend avant la suivante :

TentativeAttente après la précédente
1aucune : quelques secondes après l'événement
25 minutes
330 minutes
42 heures
56 heures
612 heures
724 heures
824 heures

Après le dernier échec, la livraison est abandonnée : elle reste dans le tableau de bord, où vous pouvez la réessayer. Les nouvelles tentatives tournent toutes les cinq minutes, donc une attente peut durer jusqu'à cinq minutes de plus. Un webhook dont toutes les tentatives échouent pendant trois jours est arrêté ; redémarrez-le depuis le tableau de bord une fois votre endpoint réparé.

Les livraisons sont au moins une fois et sans ordre garanti :

  • Vous pouvez recevoir un événement deux fois. Gardez le webhook-id de ce que vous avez traité et ignorez-le la deuxième fois.
  • Deux événements peuvent arriver dans le désordre. Ordonnez-les par sequence, ou par occurredAt pour l'heure à laquelle ils se sont produits.

Livrer à nouveau

Depuis Intégrations → Envois dans le tableau de bord, ou par l'API :

  • Réessayer une livraison : POST /api/event-deliveries/{id}/retry.
  • Renvoyer… une plage d'événements : POST /api/event-consumers/{id}/replay avec fromSequence et toSequence, au plus 1 000 événements à la fois. Les événements enregistrés pendant que le webhook était arrêté sont inclus.

Un événement livré à nouveau garde son id, donc votre endpoint le reconnaît.

Événements de test

Envoyer un test dans le tableau de bord, ou POST /api/event-consumers/{id}/test avec un type facultatif, envoie tout de suite à votre endpoint un exemple d'un type d'événement, signé comme les autres, et montre ce que votre endpoint a répondu. Un événement de test a la sequence 0 et un id qui commence par test_ ; il n'est pas dans votre journal d'événements.

Types d'événements

Type d'événementQuand
order.createdUne commande a été passée : sur la boutique, dans le tableau de bord, par l'API ou par un agent IA.
order.updatedLe client, la destination ou les notes d'une commande ont été modifiés.
order.confirmedLe client a confirmé la commande, le plus souvent au téléphone.
order.unreachableLe client n'a pas pu être joint pour confirmer la commande.
order.preparingLa commande est en préparation.
order.readyLa commande est emballée, prête à être remise à la livraison.
order.cancelledLa commande a été annulée.
order.line_returnedDes unités d'une ligne de commande ont été refusées à la porte et sont revenues.
order.deletedLa commande a été supprimée ; l'événement la contient telle qu'elle était.
shipment.handed_offLa commande a été remise à un transporteur ou à un livreur de la boutique.
shipment.out_for_deliveryLe colis est parti chez le client.
shipment.delivery_failedUne tentative de livraison du colis a échoué.
shipment.deliveredLe colis a été livré et payé à la livraison.
shipment.return_startedLe colis revient vers la boutique.
shipment.returnedLe colis est de retour à la boutique.
shipment.cancelledL'expédition a été annulée avant la livraison.
shipment.voidedLa remise a été annulée : la commande n'est plus en livraison.

Le schéma complet de chaque type est dans la référence de l'API, sous Webhooks.

Voir aussi