Webhooks
Wamia envoie des événements JSON signés aux points qu’une clé enregistre avec apiRegisterWebhook (10 par clé au plus ; apiMyWebhooks les liste, apiDeleteWebhook en retire un). Abonnez-vous à des types, à une famille avec <famille>.*, ou à tout avec *.
Catalogue d’événements
Les familles et leurs types. connect.test est toujours autorisé ; c’est ce qu’envoie apiTestWebhook.
Catalogue en direct depuis l’API
order.*10
- order.placed
- order.confirmed
- order.preparing
- order.shipped
- order.delivered
- order.completed
- order.cancelled
- order.status_changed
- order.slice_status_changed
- order.refunded
fulfilment.*8
- fulfilment.slice_prepared
- fulfilment.slice_ready
- fulfilment.slice_handed_over
- fulfilment.slice_in_transit
- fulfilment.slice_delivered
- fulfilment.delivery_failed
- fulfilment.slice_returned_to_origin
- fulfilment.return_received
rma.*6
- rma.created
- rma.approved
- rma.rejected
- rma.received
- rma.completed
- rma.seller_contested
listing.*1
- listing.state_changed
inventory.*3
- inventory.position_changed
- inventory.back_in_stock
- inventory.out_of_stock
payout.*6
- payout.scheduled
- payout.approved
- payout.sent
- payout.settled
- payout.bounced
- payout.cancelled
penalty.*2
- penalty.applied
- penalty.reversed
connect.*4
- connect.batch_held
- connect.batch_confirmed
- connect.batch_discarded
- connect.anomaly_detected
inbound.*9
- inbound.declared
- inbound.scheduled
- inbound.rescheduled
- inbound.slot_closed
- inbound.arrived
- inbound.received
- inbound.discrepancy
- inbound.no_show
- inbound.cancelled
withdrawal.*8
- withdrawal.requested
- withdrawal.decided
- withdrawal.scheduled
- withdrawal.slot_closed
- withdrawal.ready
- withdrawal.handed_over
- withdrawal.no_show
- withdrawal.cancelled
return.*2
- return.arrived
- return.processed
Sémantique de livraison
Au moins une fois, dans l’ordre par clé
Répondez 2xx en moins de 10 secondes ; faites le travail ensuite. Dédoublonnez sur id : les relances le réutilisent. Les livraisons d’un point sont tentées dans l’ordre, mais une relance peut dépasser un événement plus récent — comparez occurredAt avant d’écraser l’état local.
Relances et désactivation automatique
Une livraison en échec est relancée jusqu’à 8 fois avec recul exponentiel — 30 secondes, puis ×3 à chaque fois, plafonné à 8 heures, avec aléa — et le numéro de tentative voyage dans Wamia-Attempt. Après 72 heures d’échec continu le point est désactivé et la boutique prévenue. Les livraisons sont conservées 30 jours : la boutique rejoue celles manquées depuis Intégrations › Webhooks une fois le point rétabli.
Test
apiTestWebhook envoie un événement connect.test signé et renvoie le code de statut et les premiers octets répondus par votre récepteur : une URL mal configurée est repérée depuis l’écran de réglages du plugin.
En-têtes d’une livraison
POST /wamia/webhook HTTP/1.1
Content-Type: application/json
User-Agent: wamia-webhooks/1
Wamia-Signature: t=1759655553,v1=5f1c0b9e…
Wamia-Delivery-Id: 1b6f0d2a-…
Wamia-Event: listing.state_changed
Wamia-Attempt: 1Contenu
{
"id": "evt_01J9ZK8Q2M3N4P5R6S7T8V9W0X",
"type": "listing.state_changed",
"occurredAt": "2026-10-05T09:12:33.120Z",
"organizationId": "7c1f9f1e-5b4e-4d8f-9a6b-2f3e4d5c6b7a",
"connectionId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"data": { "sku": "TSHIRT-RED-M", "state": "live", "previousState": "in_review" }
}Vérifier la signature
Le secret est affiché une fois à la création du point (whsec_…). Calculez HMAC-SHA256 sur <t>.<corps brut> avec la chaîne complète du secret comme clé, comparez en temps constant avec v1, et refusez les horodatages de plus de 5 minutes. Utilisez le corps brut de la requête — un JSON resérialisé change les octets.
TypeScript (Node)
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyWamiaSignature(secret: string, header: string, rawBody: string): boolean {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=') as [string, string]));
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
// Express: keep the raw body — JSON re-serialisation breaks the signature.
app.post('/wamia/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyWamiaSignature(process.env.WAMIA_WEBHOOK_SECRET!, req.header('Wamia-Signature') ?? '', raw)) {
return res.status(401).end();
}
const event = JSON.parse(raw) as { id: string; type: string; occurredAt: string; data: Record<string, unknown> };
// Deduplicate on event.id, then enqueue; answer 2xx within 10 seconds.
res.status(202).end();
});PHP
<?php
const WAMIA_TOLERANCE_SECONDS = 300;
function wamia_verify_signature(string $secret, string $header, string $rawBody): bool
{
$parts = [];
foreach (explode(',', $header) as $pair) {
[$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$key] = $value;
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > WAMIA_TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
// WordPress REST route, WooCommerce plugin: register_rest_route('wamia/v1', '/webhook', ...)
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_WAMIA_SIGNATURE'] ?? '';
if (!wamia_verify_signature(getenv('WAMIA_WEBHOOK_SECRET'), $header, $raw)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
// Deduplicate on $event['id'], queue the job (Action Scheduler), answer 202 at once.
http_response_code(202);Python
import hmac, hashlib, time
TOLERANCE_SECONDS = 300
def verify_wamia_signature(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
try:
timestamp = int(parts["t"])
given = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
message = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)
# Odoo controller: @http.route('/wamia/webhook', type='http', auth='public', methods=['POST'], csrf=False)
def wamia_webhook(self, **kwargs):
raw = request.httprequest.get_data()
header = request.httprequest.headers.get("Wamia-Signature", "")
if not verify_wamia_signature(secret, header, raw):
return Response(status=401)
event = json.loads(raw)
# Deduplicate on event["id"], enqueue with queue_job, answer 202 at once.
return Response(status=202)Adresses sources
Les livraisons partent d’adresses NAT fixes listées dans Wamia Shop, Intégrations › Webhooks. Autorisez-les si votre pare-feu l’exige, mais fiez-vous à la signature, pas à l’adresse.