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/v1Authenticate
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.
POST /uploads— returnsupload_urlandupload_idPUTthe PDF atupload_url, withContent-Type: application/pdfand noAuthorizationheader — the URL carries its own signaturePOST /uploads/{upload_id}/ingest— returnsrun_id- Poll
GET /uploads/{run_id}untilstateisready, then readproduct_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}/addEach 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.