NextSDS Docs

Overview

Authenticate, read your library, and get products into it — how the NextSDS API fits together.

The NextSDS API gives your own systems the library a prevention adviser sees on screen: every product, the data extracted from its safety data sheet, where it is held, and what it is classified as. It is a normal JSON API — no SDK required.

https://connect.nextsds.com/v1

Authenticate

Every request carries a bearer key from Settings → API keys. The key is shown once when it is created and cannot be retrieved afterwards.

curl https://connect.nextsds.com/v1/workplaces \
  -H "Authorization: Bearer nsds_live_..."

The key is the team. Every key belongs to exactly one team, and the server takes the team from the key — there is no team parameter anywhere in the API. A customer with several teams uses one key per team. Anything belonging to another team is reported as not found rather than forbidden, so a key cannot be used to discover what else exists.

A key issued from a read-only connection can call every GET, plus the two POSTs that only read (/products and /catalogue/search). Any other write answers 403.

Rate limits

600 requests a minute per key, and 60 a minute for the three calls that scan your whole library rather than fetching one row: POST /products, GET /products/facets and POST /catalogue/search. Over the limit is a 429 with a Retry-After header — back off for the seconds it names rather than retrying immediately. If you are paging a large library, raise size rather than making more calls.

Reading the library

POST /products is the list — a POST because a filter carries value lists and ranges, which have no sensible query-string spelling. Send an empty body to get everything.

{
  "filters": [{ "field": "h_codes", "op": "in", "value": ["H225"] }],
  "sort": "last_updated",
  "direction": "desc",
  "page": 1,
  "size": 50
}

Each product carries its SDS under sds, as the same columns the library screen shows: product_name, signal_word, pictograms, un_number, flash_point, the per-mode transport classes, the enrichments your team has enabled, and so on. GET /products/{id} adds every sheet linked to the product.

Everything under sds is read-only. It is what NextSDS extracted from the document, and the document is the source of truth — it changes only when a new revision is processed. What you own and can write is display_name, supplier, sku, ean, archived and your custom fields. Archiving is how a product leaves the library; there is no delete.

supplier is who delivers the product to you, as your team recorded it, and is null until someone does. It is not the manufacturer the sheet names, which is under sds: the supplier filter and sort match the sheet's organisations, not this field.

GET /products/facets lists the values a filter can actually take in your library, which is how a filter UI discovers that you hold H225 at all.

Getting a product in

Two ways, depending on whether anyone has processed the sheet before.

You have the PDF

Three calls, because extraction is asynchronous. The file goes straight to storage and never passes through the API.

  1. POST /uploads — returns upload_url and upload_id
  2. PUT the PDF at upload_url, with Content-Type: application/pdf and no Authorization header — the URL carries its own signature
  3. POST /uploads/{upload_id}/ingest — returns run_id
  4. Poll GET /uploads/{run_id} until state is ready, then read product_id

Where the sheet lands is decided at step 3. Send nothing and NextSDS files it for you: it attaches to an existing product when one clearly matches, and otherwise creates a new one. Send product_id to attach it to a product you already have, or workplace_id to place the result at a workplace. Send an Idempotency-Key and a retry reuses the original run instead of extracting the document twice.

PDF only, 10 MB maximum.

Somebody already has

Most supplier sheets have been processed for someone. Search the shared catalogue and add what you find — nothing is uploaded and there is nothing to poll.

POST /catalogue/search        { "query": "aceton" }
POST /catalogue/{document_id}/add

Each search hit tells you whether you already hold it (in_library) and which product it is on. Adding a document you already have changes nothing and returns the same ids, so it is safe to call straight from a hit.

A catalogue hit shows identification and label elements only — enough to recognise a product. The rest of the sheet becomes readable through GET /products/{id} once it is in your library.

A product that is waiting for its sheet

You can record a product before anyone has its SDS, and attach the sheet when you find it:

POST /products/create         { "display_name": "Aceton 99%", "supplier": "Brenntag", "sku": "A-1042" }
POST /products/{id}/sds       { "document_id": "..." }

Creating is not POST /products, which is the list. A product without a sheet is not in the list either, as it is not on the library screen: read it back with GET /products/{id}. Creating is not idempotent, so a retry after a timeout makes a second product. Instead of attaching from the catalogue, you can also upload the PDF and send this product_id at the ingest step.

It becomes the product's primary sheet only if it had none — which is what takes a product out of "awaiting a sheet" and gives it extracted data. A sheet already attached to another of your products is refused rather than moved; merging products is done in the app.

Workplaces and inventory

POST /workplaces/{id}/products places products at a workplace, up to 100 at a time, whatever way they arrived. It is safe to repeat — a product already there is not duplicated.

Removing one is the call to be careful with. A placement carries its quantity and any RECESS or EMKG assessment made against it, and all three go together. So DELETE /workplaces/{id}/products/{product_id} refuses with 409 and a list of what would be lost unless you pass force=true.

Quantities

Quantities belong to the product, and the workplace is a field on each line. One product can have several lines at the same workplace (an IBC and a few drums), and a line without a workplace is a product-level stock figure.

GET    /products/{id}/quantities                  { "data": [...], "totals": [...] }
POST   /products/{id}/quantities                  { "workplace_id": "...", "amount": 200, "unit": "L", "container_count": 4 }
PATCH  /products/{id}/quantities/{quantity_id}    { "container_count": 3 }
DELETE /products/{id}/quantities/{quantity_id}

unit is kg, L or lbs, and nothing else is accepted: totals group by the exact unit, so a kgs would drop out of your totals in the app. Each line's total is amount × container_count, and totals sums them per unit without converting between units. Filter the list with workplace_id, or workplace_id=none for the product-level lines.

Adding a line at a workplace the product is not placed at places it there. Moving stock is a delete and a new line: PATCH cannot change the workplace. recipient_id is a package type from GET /recipient-types. Adding is not idempotent, so read the quantities before retrying after a timeout.

GET /products/{id}/risk-assessments reports the assessments per workplace, and lists a workplace with both null where none has been done — an unassessed placement is a finding, not an absence.

Custom fields

Your team's own fields on a product. GET /custom-field-schemas returns each group's JSON Schema, which is what tells you the field names and the group ids; PATCH /products/{id}/custom-fields writes values, validated against the current schema. Groups are addressed by id, never by name — a name gets edited when someone tidies up the wording and an integration keyed on it would break.

Files

GET /products/{id}/sds/{sdsId}/download and the attachments equivalent return a short-lived signed URL. Fetch it without your API key: the URL carries its own signature, and sending the key to the storage host would leak it there. Request a fresh URL per download rather than storing one.

Also available over MCP

The same operations are exposed as tools at https://connect.nextsds.com/mcp, so an AI assistant can answer questions about your library directly. Read-only connections get the read tools only.

On this page