Webhooks
The calls Nucleo makes to your endpoints, and how carriers push tracking events into Nucleo.
Two directions
Webhooks in Nucleo go both ways:
- Outbound — Nucleo calls your endpoint when something happens. Today: the CMS revalidate call, after a page or an entry is published.
- Inbound — a carrier or a tracking service calls Nucleo when a parcel moves. Nucleo matches the event to the shipment, updates the order and notifies the shopper.
CMS revalidate
Set it up in Nucleo under CMS > your site > Settings: enter your Revalidate URL and a Revalidate secret. Right after every publish Nucleo sends a signed request:
POST /api/revalidate HTTP/1.1
Host: www.acme.example
Content-Type: application/json
User-Agent: Nucleo-Webhooks/1.0
Nucleo-Signature: t=1791115200,v1=5f2b6c0e9a8d4f7b1c3e2a6d9b0f8e7c6a5d4b3c2e1f0a9b8c7d6e5f4a3b2c1d
Nucleo-Webhook-Id: 3f6c1a52-8e0b-4d7a-9c2e-1b5d7f9a0c34
Nucleo-Webhook-Event: content.published
Nucleo-Webhook-Attempt: 1
{"id":"3f6c1a52-8e0b-4d7a-9c2e-1b5d7f9a0c34","event":"content.published","site":"acme-website","locale":"en","path":"/blog","published_at":"2026-10-04T12:00:00+00:00","secret":"whsec_example_123"}- A page published sends
path: "/"(refresh the whole site); an entry published sends/<collection-slug>. - 5-second timeout, redirects not followed. Answer with any
2xx; the body is ignored. - Retries. Any other answer, or no answer in time, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours — at most 6 attempts, then Nucleo gives up. Every attempt carries the same
id(also inNucleo-Webhook-Id) and its number inNucleo-Webhook-Attempt: refreshing the cache twice is harmless, but you can skip anidyou have already handled. - The
secretfield in the body is deprecated and will be removed: verify the signature instead.
Verify the signature before doing anything. Nucleo-Signature is t=<unix time>,v1=<signature>, where the signature is the lowercase hex HMAC-SHA256 of <t>.<raw body> keyed with your Revalidate secret. Compute it on the exact bytes you received (before parsing the JSON), compare in constant time, and reject a t older than 5 minutes. A request without the header means no secret is set on the site: set one.
// Next.js route handler: app/api/revalidate/route.js
import { createHmac, timingSafeEqual } from "node:crypto";
import { revalidatePath } from "next/cache";
const TOLERANCE_SECONDS = 300;
function verify(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!secret || !parts.v1 || !Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(req) {
const rawBody = await req.text();
const signature = req.headers.get("nucleo-signature") ?? "";
if (!verify(signature, rawBody, process.env.NUCLEO_REVALIDATE_SECRET ?? "")) {
return new Response("Forbidden", { status: 403 });
}
const { path = "/" } = JSON.parse(rawBody);
revalidatePath(path === "/" ? "/" : path, "layout");
return Response.json({ revalidated: true });
}<?php
$raw = file_get_contents('php://input');
$secret = getenv('NUCLEO_REVALIDATE_SECRET') ?: '';
parse_str(str_replace(',', '&', $_SERVER['HTTP_NUCLEO_SIGNATURE'] ?? ''), $sig);
$t = (int) ($sig['t'] ?? 0);
$expected = hash_hmac('sha256', $t.'.'.$raw, $secret);
if ($secret === '' || abs(time() - $t) > 300 || !hash_equals($expected, (string) ($sig['v1'] ?? ''))) {
http_response_code(403);
exit;
}
$body = json_decode($raw, true);
// Clear your cache for $body['path'] here.
http_response_code(204);Send tracking events to Nucleo
Generic carrier connections receive events at POST https://api-commerce.nucleoplatform.com/api/oms/v1/webhooks/tracking/{connection}. The connection id and its webhook secret are given to you by the merchant or by Nucleo support when the carrier is connected.
Sign the exact bytes you send: X-Nucleo-Signature is the lowercase hex HMAC-SHA256 of the raw body, keyed with the webhook secret.
import { createHmac } from "node:crypto";
const body = JSON.stringify({
events: [
{
tracking_number: "1Z999AA10123456784",
status: "out_for_delivery",
description: "Out for delivery",
occurred_at: "2026-10-06T07:12:00+02:00",
location: "Milano",
},
],
});
const signature = createHmac("sha256", process.env.NUCLEO_WEBHOOK_SECRET).update(body).digest("hex");
await fetch(`https://api-commerce.nucleoplatform.com/api/oms/v1/webhooks/tracking/${connectionId}`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Nucleo-Signature": signature },
body,
});<?php
$body = json_encode(['events' => [[
'tracking_number' => '1Z999AA10123456784',
'status' => 'out_for_delivery',
'description' => 'Out for delivery',
'occurred_at' => '2026-10-06T07:12:00+02:00',
'location' => 'Milano',
]]]);
$signature = hash_hmac('sha256', $body, getenv('NUCLEO_WEBHOOK_SECRET'));
$client = new GuzzleHttp\Client();
$client->post("https://api-commerce.nucleoplatform.com/api/oms/v1/webhooks/tracking/$connectionId", [
'headers' => ['Content-Type' => 'application/json', 'X-Nucleo-Signature' => $signature],
'body' => $body,
]);Events for unknown tracking numbers are ignored without error, and an event identical to one already stored is skipped, so you can safely resend after a timeout. See the webhook reference for the statuses and the Qapla' and DHL formats.