Connect a warehouse
Fulfil Nucleo orders from your warehouse system: orders, picking, shipments, stock levels and returns over one REST API.
How it works
This API is part of Nucleo Orders. If Orders is not visible in the merchant's Nucleo, ask us to enable it for the company.
The Fulfillment API connects any warehouse management system (WMS) or logistics provider to a merchant on Nucleo Commerce. Your warehouse is the caller: it reads the orders to fulfil and reports back what happened. Nucleo updates the order, tells the sales channel and keeps availability in sync.
- Poll the orders to fulfil — or let a webhook tell you there is a new one.
- Accept each order (or reject it, with a reason).
- Report the picking: quantities, lots, serial numbers, parcels.
- Report the shipment: carrier and tracking. The customer gets the tracking from the sales channel.
- Keep stock levels up to date, and receive returns.
Your key only opens the merchant's locations served by your warehouse: you see only your orders, only your lines of orders split with another warehouse, and only the returns coming to you.
Split orders. An order can be split between your warehouse and another one. What works and what the merchant completes by hand is in Orders split across warehouses (v1).
Get a key and authenticate
Nucleo sets up a Fulfillment API connection for your warehouse together with the merchant, and you receive its credentials:
- The connection is created for the merchant's company. The endpoint, the API key (
nfk_…) and the webhook secret (whsec_…) are shown once: keep them in your secret store, and ask for a rotation if one leaks. - The merchant's locations your warehouse serves are linked to the connection, each with the stock location codes your WMS uses.
- Optionally, your webhook URL (
httpsonly) and your static outbound IPs (IP allowlist) are set on the connection. - The connection is turned on. Until then it is a draft and every call answers
403 connection_not_active; a paused connection answers403 connection_paused.
Call the endpoint shown with the credentials: it can be the connection's own host rather than api-commerce.nucleoplatform.com. The examples below use the default one. Send the key as a Bearer token on every call; GET /ping tells you what it opens:
curl https://api-commerce.nucleoplatform.com/api/fulfillment/v1/ping \
-H "Authorization: Bearer $NUCLEO_FULFILLMENT_KEY"const nucleo = async (path, { method = "GET", body, idempotencyKey } = {}) => {
const res = await fetch(`https://api-commerce.nucleoplatform.com/api/fulfillment/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.NUCLEO_FULFILLMENT_KEY}`,
"Content-Type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error.message), json.error, { status: res.status });
return json;
};
const ping = await nucleo("/ping");
console.log(ping.company.name, ping.locations.map((l) => l.stock_location_codes));<?php
$nucleo = new GuzzleHttp\Client([
'base_uri' => 'https://api-commerce.nucleoplatform.com/api/fulfillment/v1/',
'headers' => ['Authorization' => 'Bearer ' . getenv('NUCLEO_FULFILLMENT_KEY')],
'http_errors' => false,
]);
$ping = json_decode($nucleo->get('ping')->getBody(), true);
echo $ping['company']['name'];A missing or wrong key — or a call from an IP outside the allowlist — answers 401. After 30 failed attempts in 5 minutes the calling IP is blocked for the rest of the window. Rotating the credentials replaces the key and the webhook secret at once and invalidates the old ones immediately.
Fulfil an order
1. Read the queue. GET /orders (default status=to_fulfill) lists the orders ready for your warehouse and not yet accepted, oldest first, with everything you need: customer and shipping address, carrier and service, priority, notes, and only your lines with quantities, weights and unit prices for customs. Reading never takes an order over, so poll it every 1 to 5 minutes. A page can hold fewer orders than limit — even none — while next_cursor is not null: keep following it until it is.
curl "https://api-commerce.nucleoplatform.com/api/fulfillment/v1/orders?status=to_fulfill&limit=100" \
-H "Authorization: Bearer $NUCLEO_FULFILLMENT_KEY"let cursor = null;
do {
const page = await nucleo(`/orders?status=to_fulfill&limit=100${cursor ? `&cursor=${cursor}` : ""}`);
for (const order of page.data) await importIntoWms(order);
cursor = page.next_cursor;
} while (cursor);<?php
$cursor = null;
do {
$query = ['status' => 'to_fulfill', 'limit' => 100] + ($cursor ? ['cursor' => $cursor] : []);
$page = json_decode($nucleo->get('orders', ['query' => $query])->getBody(), true);
foreach ($page['data'] as $order) {
importIntoWms($order);
}
$cursor = $page['next_cursor'];
} while ($cursor !== null);2. Accept it once it is in your WMS, with your own reference. It leaves the queue and its items stop being reserved out of the stock you report. If you cannot take it (address incomplete, item unknown), reject it with a reason code instead: the merchant fixes it and it comes back.
curl -X POST https://api-commerce.nucleoplatform.com/api/fulfillment/v1/orders/1042/accept \
-H "Authorization: Bearer $NUCLEO_FULFILLMENT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: accept-1042" \
-d '{"wms_reference": "VR-2026-004211"}'await nucleo("/orders/1042/accept", {
method: "POST",
body: { wms_reference: "VR-2026-004211" },
idempotencyKey: "accept-1042",
});<?php
$nucleo->post('orders/1042/accept', [
'headers' => ['Idempotency-Key' => 'accept-1042'],
'json' => ['wms_reference' => 'VR-2026-004211'],
]);3. Report the picking with what you actually picked, by sku or barcode, and the parcels. It is all or nothing: one item that is not a line of the order assigned to you rejects the call with 422 unknown_item and nothing is recorded. A quantity lower than ordered marks the missing pieces as unfulfillable; the merchant is alerted and, if the merchant enabled it, the customer is refunded for them. You can send the picking again until you ship: the last report counts. A later report with more pieces missing refunds only the difference; a refund already made is never taken back, so a correction with fewer pieces missing is flagged to the merchant — report the picking when it is complete.
curl -X POST https://api-commerce.nucleoplatform.com/api/fulfillment/v1/orders/1042/picked \
-H "Authorization: Bearer $NUCLEO_FULFILLMENT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: picked-1042" \
-d '{
"lines": [
{"sku": "TEE-BLK-M", "quantity": 1},
{"barcode": "2000000000022", "quantity": 2, "lot_number": "L2026-41"}
],
"parcels": [{"reference": "PK-1", "weight_g": 1540}]
}'await nucleo("/orders/1042/picked", {
method: "POST",
idempotencyKey: "picked-1042",
body: {
lines: [
{ sku: "TEE-BLK-M", quantity: 1 },
{ barcode: "2000000000022", quantity: 2, lot_number: "L2026-41" },
],
parcels: [{ reference: "PK-1", weight_g: 1540 }],
},
});<?php
$nucleo->post('orders/1042/picked', [
'headers' => ['Idempotency-Key' => 'picked-1042'],
'json' => [
'lines' => [
['sku' => 'TEE-BLK-M', 'quantity' => 1],
['barcode' => '2000000000022', 'quantity' => 2, 'lot_number' => 'L2026-41'],
],
'parcels' => [['reference' => 'PK-1', 'weight_g' => 1540]],
],
]);4. Report the shipment with the carrier and one tracking number per parcel. Nucleo marks the order shipped and creates the fulfilment on the sales channel. The same tracking number sent again is a retry: 200 with the shipment already recorded, never a second fulfilment.
curl -X POST https://api-commerce.nucleoplatform.com/api/fulfillment/v1/orders/1042/shipments \
-H "Authorization: Bearer $NUCLEO_FULFILLMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"carrier_code": "EXAMPLE", "service_code": "EXPRESS", "parcels": [{"tracking_number": "064152003391", "weight_g": 1540}]}'const shipment = await nucleo("/orders/1042/shipments", {
method: "POST",
body: { carrier_code: "EXAMPLE", service_code: "EXPRESS", parcels: [{ tracking_number: "064152003391", weight_g: 1540 }] },
});
console.log(shipment.order_status); // "shipped"<?php
$res = $nucleo->post('orders/1042/shipments', ['json' => [
'carrier_code' => 'EXAMPLE',
'service_code' => 'EXPRESS',
'parcels' => [['tracking_number' => '064152003391', 'weight_g' => 1540]],
]]);
// 201 = new shipment, 200 = the same tracking number was already recordedCannot ship an order you accepted? POST /orders/{number}/cancel with a reason code: the order is cancelled and the merchant refunds the customer. On an order split with another warehouse — even if the other warehouse already shipped its part — cancel gives up only your part: what is left of your share becomes unfulfillable (refunded once, if the merchant enabled it), your part turns closed and picked and shipments on it answer 409 invalid_state, while the other warehouse goes on with its own.
Accepting again an order you already picked answers 409 invalid_state; one you already shipped, 409 order_already_shipped.
Labels made by Nucleo. When shipping.labels_by is nucleo, the merchant has Nucleo create the carrier labels: the first one is ready right after you accept the order. Download them with GET /orders/{number}/labels, ask for more parcels with POST /orders/{number}/labels, and report the shipment without carrier_code and parcels — the shipment gets one parcel per ready label, with the labels' tracking numbers and the carrier and service of the order (shipping.carrier_code, shipping.service_code), and sending it again answers 200 with the same shipment. At most 10 labels per order: with Nucleo labels, parcels takes at most 10 items. After the shipment, GET /orders/{number}/labels still shows the labels it left with. Labels can be created only once you accepted your part; after a partial shipment (lines), POST /orders/{number}/labels creates new labels for the rest and the next shipment without parcels takes them. With no ready label the shipment answers 409 labels_not_ready. If you send parcels in the picking report, each one gets its own label and keeps its tracking number; your references, weights and sizes are added to it. GET /orders/{number}/documents returns the invoice, customs documents and inserts to put in the parcel.
Cancellations. An order cancelled before you accept it simply leaves the queue. Cancelled after you accepted it, it turns cancel_requested (and you get the order.cancelled webhook): stop picking if you still can and call cancel to confirm you stopped, or report the shipment if the parcel already left — never both. The order is then closed: cancelled, or — if part of it already left — that part stays shipped and only the pieces not shipped are cancelled and refunded. Split orders work differently: see below. accept, reject and picked answer 409 order_cancel_requested.
See List orders, Report the picking and Report a shipment for every field.
Orders split across warehouses (v1)
An order can be split between your warehouse and another one — by line, or even within a line (for example 2 pieces from you and 1 from the other warehouse).
What works
- You see only your lines, and every quantity you read and report — ordered, open, picked, unfulfillable, shipped, cancelled — is your share of the line. Your picking and your shipments never change the other warehouse's share, and a shipment contains only your share.
- Until you accept the order, your part stays reserved out of the stock you report, even if the other warehouse already took its own.
cancelgives up only your part — also after the other warehouse shipped its own: your part becomesclosed, what is left of your share becomes unfulfillable (refunded once, if the merchant enabled automatic refunds), andpickedandshipmentson it answer409 invalid_state.
What the merchant completes by hand
- When the merchant cancels a split order, Nucleo does not close it or refund it automatically in v1. The order stays
cancel_requestedfor every warehouse that accepted it and still has something to ship. - Confirm with
cancelthat you stopped (your part then readscancelled), or report the shipment if the parcel already left (it stays shipped). - The merchant sees the state of every part and completes the cancellation and the refund in Nucleo. A warehouse that never accepted the order sees it as
cancelledand has nothing to confirm (409 order_not_accepted).
Keep stock levels up to date
PUT /stock takes absolute levels — not increments — per item, stock location code and classification, at most 500 items per call. Send the full stock once a day and only what changed in between: items you do not send keep their last level.
curl -X PUT https://api-commerce.nucleoplatform.com/api/fulfillment/v1/stock \
-H "Authorization: Bearer $NUCLEO_FULFILLMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"items": [
{"sku": "TEE-BLK-M", "quantity": 42, "counted_at": "2026-10-13T06:00:00Z"},
{"barcode": "2000000000022", "quantity": 3, "classification": "damaged"}
]}'const batches = (items, size = 500) => Array.from({ length: Math.ceil(items.length / size) }, (_, i) => items.slice(i * size, i * size + size));
for (const items of batches(levelsFromWms)) {
const result = await nucleo("/stock", { method: "PUT", body: { items } });
for (const skipped of result.ignored) console.warn(skipped.reason, skipped.sku ?? skipped.barcode);
}<?php
foreach (array_chunk($levelsFromWms, 500) as $items) {
$result = json_decode($nucleo->put('stock', ['json' => ['items' => $items]])->getBody(), true);
foreach ($result['ignored'] as $skipped) {
error_log($skipped['reason'] . ': ' . ($skipped['sku'] ?? $skipped['barcode']));
}
}- Report stock net of the orders you have accepted. Nucleo subtracts the orders you have not accepted yet before publishing availability, so nothing is sold twice.
- Only
sellablestock (the default) is published as available to the sales channels.damaged,quarantine,reservedandunsellableare stored for the merchant; if the merchant chooses so, they are also published to the sales channel as damaged (not sellable) stock. - Send
counted_at(without it, the time Nucleo receives the call counts): a level older than the last one applied for the same item, location code and classification is not applied and is listed inignoredwith reasonstale, so a late retry cannot roll stock back. location_codeis one of thestock_location_codesfrom/ping; you can omit it when there is only one.- Unknown items are skipped and listed in
ignoredwith reasonunknown_item(the merchant is alerted); the others are applied and counted inaccepted.
Receive returns
Returns approved by the merchant and addressed to your warehouse appear in GET /returns?status=expected (and trigger the return.expected webhook). Confirm you expect one with POST /returns/{id}/accept, then, when the parcel arrives, report what was in it — every item with its condition:
curl -X POST https://api-commerce.nucleoplatform.com/api/fulfillment/v1/returns/R1042-1/receipt \
-H "Authorization: Bearer $NUCLEO_FULFILLMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"lines": [
{"sku": "TEE-BLK-M", "quantity": 1, "condition": "sellable"},
{"sku": "HOOD-GRY-L", "quantity": 1, "condition": "damaged", "note": "Stain on the left sleeve."}
]}'const ret = await nucleo("/returns/R1042-1/receipt", {
method: "POST",
body: {
lines: [
{ sku: "TEE-BLK-M", quantity: 1, condition: "sellable" },
{ sku: "HOOD-GRY-L", quantity: 1, condition: "damaged", note: "Stain on the left sleeve." },
],
},
});
console.log(ret.outcome); // "not_compliant"<?php
$ret = json_decode($nucleo->post('returns/R1042-1/receipt', ['json' => ['lines' => [
['sku' => 'TEE-BLK-M', 'quantity' => 1, 'condition' => 'sellable'],
['sku' => 'HOOD-GRY-L', 'quantity' => 1, 'condition' => 'damaged', 'note' => 'Stain on the left sleeve.'],
]]])->getBody(), true);- Conditions:
sellable,damaged,defective,used,wrong_item. - The return is compliant when every expected item arrived in the expected quantity and
sellable; otherwise not compliant, and the merchant's customer service takes over. Refunds and exchanges are the merchant's business, not the warehouse's. - Report returned stock separately with
PUT /stock: a receipt does not change stock levels. - A second receipt updates only the items it lists — the others keep what you reported before — and the outcome is evaluated again. To correct, list every item again.
- A receipt for a return that is cancelled or already closed is recorded anyway and the merchant is alerted: the goods are in your warehouse and need a decision.
- A parcel nobody announced?
POST /returns/unannouncedwith the order number, your parcel reference and the lines. Nucleo creates the return and alerts the merchant. Sending the same content again returns the same return. Only for orders your warehouse fulfilled: another warehouse's order answers404. status=cancelledlists returns that will not come: stop waiting for them.
Retries and idempotency
Networks fail. Every write can be retried safely:
- Send an
Idempotency-Keyheader (any unique string, for example a UUID oraccept-1042) onPOSTandPUT. Keys belong to your connection and last 48 hours. The same key with the same request — same method, same path written the same way (/orders/1042and/orders/%231042are different) and same body — returns the first answer without doing the work again, with the headerIdempotent-Replayed: true. The replayed body keeps the originalrequest_id; theX-Request-Idheader is the new call's. - The same key with a different request answers
409 idempotency_key_reused: use a new key for a new request. A key still being processed by a parallel call answers409 idempotency_key_in_progresswithRetry-After: 1; a key left in progress for more than 2 minutes (a call interrupted half-way) is free again. - Server errors (
5xx) and429are not stored: retry them with the same key. - Without a key the API is still safe where it matters: accepting, rejecting and cancelling twice change nothing, a shipment is idempotent on its first tracking number, stock levels are absolute and an unannounced return with the same content is created once.
Errors
Every error has the same shape and the right HTTP status. Branch on code, show message to people, and quote request_id (also the X-Request-Id header of every response) when you contact support: it identifies the call in Nucleo's logs, which the merchant can see too.
{
"error": {
"code": "validation_failed",
"message": "The request is not valid.",
"request_id": "1f7e3c2a-9b8d-4e6f-a5c4-3b2a1d0e9f8c",
"details": {
"lines.0.quantity": ["The lines.0.quantity field must be at least 0."]
}
}
}| Status | Codes | What to do |
|---|---|---|
400 | validation_failed (with details per field), invalid_json, invalid_cursor | Fix the request. Nothing was changed. |
401 | unauthorized | Check the key and the IP allowlist. |
403 | connection_paused, connection_not_active | The merchant paused the connection or has not turned it on: retry later. Nothing was read or changed. |
404 | order_not_found, return_not_found, not_found | It does not exist or belongs to another warehouse. |
405 | method_not_allowed | The path exists with another method: check the reference. |
409 | order_not_ready, order_not_accepted, order_already_accepted, order_cancelled, order_cancel_requested, order_already_shipped, invalid_state, documents_not_ready, labels_not_ready, idempotency_key_reused, idempotency_key_in_progress | The order or return is not in the right state: read it again before deciding. |
413 | payload_too_large | Bodies are limited to 5 MB: split the call. |
422 | unknown_item, too_many_items, labels_not_enabled | Valid JSON that cannot be applied. Nothing was changed. |
429 | rate_limited | Wait the seconds in Retry-After. |
500 | internal_error | Retry with backoff (with the same Idempotency-Key); if it persists, contact support with the request_id. |
Limits: by default 600 calls per minute per connection, all endpoints together (the merchant can set another limit on the connection); lists return at most 200 items per page; stock updates take at most 500 items.
Webhooks
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.
Go-live checklist
Run these against a test company with test orders before switching production. Use a test company that is not a demo: on demo companies Nucleo sends no webhooks and writes nothing to the sales channels.
| # | Scenario | Expected |
|---|---|---|
| 1 | GET /ping without, then with the key | 401, then your company and locations |
| 2 | Read to_fulfill twice without accepting | The same orders both times, nothing changes in Nucleo |
| 3 | Accept an order, read the queue again | The order is accepted and no longer in to_fulfill |
| 4 | Reject an order with a reason code | rejected; the merchant sees the reason |
| 5 | Report a full picking with two parcels, then the shipment; send the shipment twice | processed, then shipped with tracking on the sales channel; 201, then 200 with the same shipment |
| 6 | Report a short picking | Missing pieces unfulfillable, merchant alerted |
| 7 | Report an item that is not on the order | 422 unknown_item, nothing recorded |
| 8 | Cancel an accepted order | cancelled, merchant alerted |
| 9 | Merchant cancels an accepted order; you call cancel | cancel_requested and the order.cancelled webhook; then cancelled |
| 10 | PUT /stock with a known and an unknown item, then the known one with an older counted_at | accepted: 1 and the unknown one in ignored (unknown_item); then accepted: 0 and the item in ignored (stale) |
| 11 | Confirm and receive a return, all sellable; then one damaged | compliant; then not_compliant |
| 12 | Report an unannounced return twice | One return, created once |
| 13 | Repeat a write with the same Idempotency-Key, then with another body | Same answer with Idempotent-Replayed: true; then 409 |
| 14 | Verify the signature of a webhook; reject one with a wrong secret | Accepted; rejected |
| 15 | Page through 300 orders with limit=100 | Every order exactly once |
Ready for production when every scenario passes and the production connection has its own key.