Warehouse webhooks
The calls Nucleo makes to your warehouse system when an order is ready, cancelled or a return is expected.
When the merchant sets your webhook URL on the connection, Nucleo calls it — so you can poll less often. Three events:
| Event | When | Then |
|---|---|---|
order.ready | An order assigned to your warehouse is ready. | GET /orders/{number}, then accept it. |
order.cancelled | An order that was ready is cancelled. data.status is cancelled, or cancel_requested if you had accepted it. | Stop it in your WMS. |
return.expected | A return to your warehouse is approved. | GET /returns/{id}, then confirm it. |
Webhooks are hints, not data: the body carries only the IDs, and you read the current state with the API. Keep polling as a fallback (for example every 15 minutes): an order can also become ready later than its event, and a webhook can fail.
POST /hooks/nucleo HTTP/1.1
Host: wms.acme.example
Content-Type: application/json
User-Agent: Nucleo-Webhooks/1.0
X-Nucleo-Signature: t=1791880191,v1=5f2b6c0e9a8d4f7b1c3e2a6d9b0f8e7c6a5d4b3c2e1f0a9b8c7d6e5f4a3b2c1d
X-Nucleo-Event: order.ready
X-Nucleo-Webhook-Id: 5b7e1c2d-3a4f-4b6c-8d9e-0f1a2b3c4d5e
X-Nucleo-Attempt: 1
{"id":"5b7e1c2d-3a4f-4b6c-8d9e-0f1a2b3c4d5e","type":"order.ready","created_at":"2026-10-13T08:29:51Z","data":{"order_id":"3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b","order_number":"#1042"}}Verify the signature before doing anything. X-Nucleo-Signature is t=<unix time>,v1=<signature>, where the signature is the lowercase hex HMAC-SHA256 of <t>.<raw body> keyed with the webhook secret (whsec_…). Compute it on the exact bytes you received, compare in constant time, and reject a t more than 5 minutes away from your clock.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
function verify(header, rawBody, secret) {
const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!parts.v1 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
app.post("/hooks/nucleo", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(req.get("X-Nucleo-Signature"), raw, process.env.NUCLEO_WEBHOOK_SECRET)) return res.sendStatus(403);
const event = JSON.parse(raw);
if (await alreadyHandled(event.id)) return res.sendStatus(200);
await queue.push(event); // answer fast, work later
res.sendStatus(200);
});<?php
$raw = file_get_contents('php://input');
$secret = getenv('NUCLEO_WEBHOOK_SECRET');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_NUCLEO_SIGNATURE'] ?? ''), $sig);
$t = (int) ($sig['t'] ?? 0);
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (abs(time() - $t) > 300 || !hash_equals($expected, (string) ($sig['v1'] ?? ''))) {
http_response_code(403);
exit;
}
$event = json_decode($raw, true);
// Skip $event['id'] if you already handled it, then queue the work.
http_response_code(200);- Answer with any
2xxwithin 10 seconds; redirects are not followed. Anything else is retried with exponential backoff — 8 attempts in about an hour — then Nucleo gives up and alerts the merchant. - Every attempt carries the same
id(also inX-Nucleo-Webhook-Id) and its number inX-Nucleo-Attempt: skip anidyou have already handled. - While the connection is paused or still a draft, no webhook is sent, and the events of that time are not sent later: catch up by polling.
- The webhook URL must be
httpsand reachable from the internet.