Skip to content

Bulk import

Setting up jtWMS usually starts with data you already have: a product list, the people who will use the system. These endpoints take that data in batches, so a script or an integration can load it in a few calls.

The web app’s Import data page reads CSV files and sends them to the same endpoints:

  • each row of its locations template is a position. Rows in areas the facility doesn’t have go in one topology import; rows in existing areas are created node by node (see Locations);
  • each row of its products template becomes one product (the base unit and up to two packs as unitsOfMeasure), sent 500 at a time;
  • each row of its starting stock template becomes one receipt line, sent 100 at a time (see Starting stock);
  • each row of its team members template becomes one entry of invites, sent 100 at a time after a dry run.

A facility’s locations are its storage tree’s positions: each Position node owns one location, whose code the facility’s code pattern builds from the codes of the node and its ancestors. Storage node codes are unique across a facility. Two endpoints create them, both needing warehouseconfig.topology.manage:

  • POST /api/v1/facilities/{facilityId}/topology/import?mode=additive creates whole new areas, with everything under them and each position’s locationAttributes, all or nothing. A code the facility already has is a 409 listing the conflictingCodes, and nothing is saved. The document is the one GET …/topology/export returns, with formatVersion 1.0.
  • POST /api/v1/facilities/{facilityId}/storage-nodes creates one node under a parentId, for adding to an area that exists. A position created this way gets default attributes; set its location’s with PUT /api/v1/facilities/{facilityId}/locations/{locationId}, finding the locationId in GET …/storage-nodes/tree.

POST /api/v1/facilities/{facilityId}/inventory/receipts receives up to 100 lines as one receipt, all or nothing. It needs inventory.stock.manage. Lines name products, locations and license plates by id: find them by SKU in GET /api/v1/products/snapshot, by code in GET /api/v1/facilities/{facilityId}/locations?search=, and by plate code in GET …/inventory/lookup?code=. Create a missing plate with POST …/inventory/lpns (inventory.movements.manage).

A receipt is not repeated for you, so make a batch safe to send again with its reference: before sending, read GET …/inventory/transactions?type=Receipt&reference=<reference>, and skip the batch when the ledger already has it. The web app’s references are import: followed by the first 12 hex digits of the SHA-256 of the batch’s lines and the batch’s number.

Products and locations reach the stock service through events, so for a moment after you create one a receipt can answer 404 with errorCode product_not_found or location_not_found. Wait and send it again: the web app retries with growing pauses for up to a minute.

POST /api/v1/products/bulk creates or updates up to 500 products in one request, matched by SKU (ignoring case). It needs catalog.products.manage.

  • All or nothing. If any product is invalid, nothing is saved and the response lists every problem, keyed by the product’s position (Products[3].Sku).
  • Safe to repeat. A product whose SKU already exists is replaced with what you send; one that hasn’t changed is reported unchanged. Sending the same file twice changes nothing the second time, so a script can split a large list into batches of 500 and simply retry a batch that failed.
  • The response counts the products created, updated and unchanged, and lists each SKU with its id and result.

POST /api/v1/team/invites/bulk invites up to 100 people in one request. Each person is emailed a link to set up their account. It needs identity.users.manage.

{
"invites": [
{ "email": "ana@example.com", "roleIds": ["<role id>"] },
{ "email": "ben@example.com", "roleIds": [] }
],
"expiresInDays": 7,
"replaceExisting": false
}

Find role ids with GET /api/v1/team/roles. The request is checked as a whole first, and refused with nothing saved when:

  • a row is malformed (400, keyed by the row, such as Invites[1].Email);
  • a role id isn’t one of your organisation’s roles (400 with errorCode invite_role_unknown, keyed by the row’s Invites[i].RoleIds);
  • a role grants a permission you don’t hold yourself (403 with errorCode permission_not_held): see Permissions and scopes;
  • the invites would pass your organisation’s daily limit of 200 (429 with errorCode invite_daily_limit). Single and bulk invites count together, over the last 24 hours. Requests sent at the same moment are each checked against the invites saved before them, so a burst of them can go slightly past the limit.

Otherwise each row gets its own result, in the order you sent them, and the rows that can be invited are, whatever happens to the others:

status Meaning
created Invited. The row carries the invite’s inviteId, inviteUrl and expiresAt.
duplicate_in_request The same email appears earlier in the request (ignoring case).
already_member Someone in your organisation already uses this email.
email_in_use The email belongs to a jtWMS account elsewhere, so it can’t be invited.
already_invited The email has an invite waiting. Send "replaceExisting": true to send a new one; the old link then stops working.

Add ?dryRun=true to see the results without inviting anyone or sending email, for example to check a list before sending it.