Menu

Webhooks

Wamia posts signed JSON events to the endpoints a key registers with apiRegisterWebhook (up to 10 per key; apiMyWebhooks lists them, apiDeleteWebhook removes one). Subscribe to single types, to a family with <family>.*, or to everything with *.

Event catalogue

Families and their types. connect.test is always allowed and is what apiTestWebhook sends.

Live catalogue from the 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

Delivery semantics

At least once, in order per key

Answer any 2xx within 10 seconds; do the work afterwards. Deduplicate on id: retries reuse it. Deliveries for one endpoint are attempted in order, but a retry can overtake a newer event — compare occurredAt before overwriting local state.

Retries and auto-disable

A failed delivery is retried up to 8 times with exponential backoff — 30 seconds, then ×3 each time, capped at 8 hours, with jitter — and the attempt number travels in Wamia-Attempt. After 72 hours of continuous failure the endpoint is disabled and the shop is told. Deliveries are kept 30 days: the shop replays missed ones from Integrations › Webhooks once the endpoint is healthy again.

Testing

apiTestWebhook posts a signed connect.test event and returns the status code and the first bytes your receiver answered, so a misconfigured URL is caught from the plugin’s settings screen.

Headers of a delivery

HTTP
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: 1

Payload

JSON
{
  "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" }
}

Verifying the signature

The secret is shown once when the endpoint is created (whsec_…). Compute HMAC-SHA256 over <t>.<raw body> with the full secret string as key, compare in constant time with v1, and refuse timestamps older than 5 minutes. Use the raw request body — re-serialised JSON changes bytes.

TypeScript (Node)

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
<?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

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)

Source addresses

Deliveries leave from fixed NAT addresses listed in Wamia Shop under Integrations › Webhooks. Allow-list them if your firewall needs it, but rely on the signature, not the address.