#SafetyDataSheetAPI
Turn safety data sheet PDFs into structured, reviewable data. One request returns a fixed 16-section JSON record plus XML and CSV, with a confidence score and warnings.
The API takes a PDF and returns the same record structure every time: sixteen sections following the SDS layout, with fixed field names. Values the document does not contain are null. Nothing is guessed to fill a gap, and the model's uncertainty is exposed through confidence_score and warnings.
- Base URL
https://safetydatasheetapi.com - Format multipart upload in, JSON out (XML and CSV included)
- Languages any SDS language; the detected language is returned as
document_language - Price $0.25 per document of up to 40 pages, prepaid, failures not billed
#Quickstart
Create a key, send a PDF, read the result.
- Create an account and an API key. Sign in, open API keys and create one. It starts with
sds_live_and is shown once. - Add credit. The extraction is billed to your prepaid balance. New accounts have none: open Billing in the dashboard and buy a credit pack by card. You can check the balance with GET /api/account.
- Send a PDF. Post it as multipart form-data to
/extract-sdswith your key. - Store the result. Keep
request_id, the JSON record, and thewarningstogether, and route low-confidence documents to review.
Prefer a client library? See the Python and Node.js clients and the Postman collection.
curl -X POST https://safetydatasheetapi.com/extract-sds \
-H "Authorization: Bearer $SDS_API_KEY" \
-F "[email protected]" \
-F "language_hint=en" \
-F "schema_version=1.0"import os
import requests
response = requests.post(
"https://safetydatasheetapi.com/extract-sds",
headers={"Authorization": f"Bearer {os.environ['SDS_API_KEY']}"},
files={"file": open("acetone-sds.pdf", "rb")},
data={"language_hint": "en", "schema_version": "1.0"},
timeout=120,
)
response.raise_for_status()
result = response.json()
print(result["confidence_score"], result["warnings"])
print(result["outputs"]["json"]["product_identification"]["product_name"])import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("file", new Blob([await readFile("acetone-sds.pdf")], { type: "application/pdf" }), "acetone-sds.pdf");
form.append("language_hint", "en");
form.append("schema_version", "1.0");
const response = await fetch("https://safetydatasheetapi.com/extract-sds", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SDS_API_KEY}` },
body: form,
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const result = await response.json();
console.log(result.confidence_score, result.warnings);
console.log(result.outputs.json.product_identification.product_name);{
"ok": true,
"request_id": "9a1f6c2e5b3d4e7f-AMS",
"output_formats": ["JSON", "XML", "CSV"],
"confidence_score": 0.94,
"document_language": "en",
"is_safety_data_sheet": true,
"schema_version": "1.0",
"warnings": [],
"outputs": {
"json": {
"product_identification": { "product_name": "Acetone", "supplier_name": "Example Chemicals Ltd" },
"hazards_identification": {
"signal_word": "Danger",
"h_statements": ["H225 Highly flammable liquid and vapour", "H319 Causes serious eye irritation"]
},
"composition": [
{ "substance_name": "Acetone", "cas_number": "67-64-1", "ec_number": "200-662-2", "concentration_percent": "95-100" }
],
"transport_information": { "un_number": "UN1090", "hazard_class": "3", "packing_group": "II" },
"revision_metadata": { "revision_date": "2024-01-15" }
},
"xml": "<?xml version=\"1.0\" ...>",
"csv": "section,field,value\n..."
},
"metadata": { "file_name": "acetone-sds.pdf", "file_size_bytes": 184233, "language_hint": "en",
"document_language": "en", "schema_version": "1.0" },
"billing": { "pages": 12, "documents_billed": 1, "cost_usd": 0.25 },
"processing_ms": 14210
}#Authentication
Authenticate every request with an API key.
Send the key as a bearer token. The alternative X-API-Key header carries the same key.
Authorization: Bearer sds_live_<id>_<secret>- Keys belong to an account. Create, label and revoke them in the dashboard (up to 10 active keys).
- Only a hash is stored. If a key is lost, revoke it and create another.
- Never put a key in a URL or in client-side code. Call the API from your server.
- Invalid or revoked keys return
401; a suspended account returns403.
curl https://safetydatasheetapi.com/api/account \
-H "Authorization: Bearer $SDS_API_KEY"#Extract a document
Synchronous extraction of one PDF. Typical processing time is 10 to 30 seconds depending on length.
POST/extract-sds
Send multipart/form-data. Only PDF files are accepted, up to 10 MB and 400 pages.
| Name | Type | Description |
|---|---|---|
file | filerequired | The SDS or MSDS as a PDF. |
language_hint | stringoptional | An ISO language such as en or de. A hint only; the detected language is returned separately as document_language. |
schema_version | stringoptional | Pins the output schema. Only 1.0 exists today; other values return 400 unsupported_schema_version. |
A successful response is 200 with the object described in The response. Failures return a JSON error and are not billed; see Errors.
outputs.json for application logic and keep request_id for support and audits.curl -X POST https://safetydatasheetapi.com/extract-sds \
-H "Authorization: Bearer $SDS_API_KEY" \
-F "[email protected]" \
-F "language_hint=en" \
-F "schema_version=1.0"import os
import requests
response = requests.post(
"https://safetydatasheetapi.com/extract-sds",
headers={"Authorization": f"Bearer {os.environ['SDS_API_KEY']}"},
files={"file": open("acetone-sds.pdf", "rb")},
data={"language_hint": "en", "schema_version": "1.0"},
timeout=120,
)
response.raise_for_status()
result = response.json()
print(result["confidence_score"], result["warnings"])
print(result["outputs"]["json"]["product_identification"]["product_name"])import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("file", new Blob([await readFile("acetone-sds.pdf")], { type: "application/pdf" }), "acetone-sds.pdf");
form.append("language_hint", "en");
form.append("schema_version", "1.0");
const response = await fetch("https://safetydatasheetapi.com/extract-sds", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SDS_API_KEY}` },
body: form,
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const result = await response.json();
console.log(result.confidence_score, result.warnings);
console.log(result.outputs.json.product_identification.product_name);#The response
Every success has the same shape.
| Field | Type | Description |
|---|---|---|
ok | boolean | Always true on success. |
request_id | string | Trace identifier. Quote it when contacting support. |
confidence_score | number | 0 to 1. The model's own estimate for the whole document; it is not a calibrated accuracy measure. |
warnings | string[] | Ambiguities, missing or conflicting values and format problems. Treat a non-empty list as a signal to review. |
document_language | string | null | Language the document is written in (sv, pt-BR), or null. |
is_safety_data_sheet | boolean | null | false when the file does not look like a safety data sheet (a warning is added). |
schema_version | string | The schema applied to outputs.json (1.0). |
outputs.json | object | The record: sixteen sections with fixed field names. See The 16 sections. |
outputs.xml / outputs.csv | string | The same record as XML and CSV. Arrays become repeated elements in XML; CSV cells that start with = + - @ get a leading apostrophe. |
metadata | object | File name, size, language hint, detected language and schema version. |
billing | object | pages, documents_billed and cost_usd for this request. |
processing_ms | integer | Server-side processing time. |
Unknown or missing values are never invented: a value that is not in the document is null and a list that is not in the document is empty.
{
"ok": true,
"request_id": "9a1f6c2e5b3d4e7f-AMS",
"output_formats": ["JSON", "XML", "CSV"],
"confidence_score": 0.94,
"document_language": "en",
"is_safety_data_sheet": true,
"schema_version": "1.0",
"warnings": [],
"outputs": {
"json": {
"product_identification": { "product_name": "Acetone", "supplier_name": "Example Chemicals Ltd" },
"hazards_identification": {
"signal_word": "Danger",
"h_statements": ["H225 Highly flammable liquid and vapour", "H319 Causes serious eye irritation"]
},
"composition": [
{ "substance_name": "Acetone", "cas_number": "67-64-1", "ec_number": "200-662-2", "concentration_percent": "95-100" }
],
"transport_information": { "un_number": "UN1090", "hazard_class": "3", "packing_group": "II" },
"revision_metadata": { "revision_date": "2024-01-15" }
},
"xml": "<?xml version=\"1.0\" ...>",
"csv": "section,field,value\n..."
},
"metadata": { "file_name": "acetone-sds.pdf", "file_size_bytes": 184233, "language_hint": "en",
"document_language": "en", "schema_version": "1.0" },
"billing": { "pages": 12, "documents_billed": 1, "cost_usd": 0.25 },
"processing_ms": 14210
}#The 16 sections
outputs.json always contains these keys, in this order.
| Key | Contains |
|---|---|
product_identification | Product name, identifiers, supplier and emergency contact, recommended uses. |
hazards_identification | GHS classification, signal word, hazard (H, EUH) and precautionary (P) statements, pictograms. |
composition | Substances with CAS and EC numbers, concentration, and REACH registration data. |
first_aid | Measures by exposure route and notes for physicians. |
fire_fighting | Extinguishing media, hazards from combustion, firefighter protection. |
accidental_release | Personal precautions, environmental precautions, containment and clean-up. |
handling_storage | Safe handling, storage conditions, incompatibilities, storage class. |
exposure_controls_ppe | Occupational exposure limits, engineering controls, protective equipment. |
physical_chemical_properties | Appearance, flash point, boiling point, density, pH, vapour pressure and more. |
stability_reactivity | Reactivity, stability, hazardous reactions and decomposition. |
toxicological_information | Acute and chronic effects, routes of exposure, toxicity values. |
ecological_information | Ecotoxicity, persistence, bioaccumulation, water hazard class. |
disposal_considerations | Waste treatment and disposal methods. |
transport_information | UN number, proper shipping name, class, packing group, per transport mode. |
regulatory_information | Regulations, inventories, SVHC and other regulatory statements. |
revision_metadata | Revision date, version and issue information. |
Inside each section the field names are fixed as well, about 100 fields in all. The machine-readable definition is a JSON Schema at /schema/sds-schema-v1.json, and the rules for changing it are in the schema versioning guide.
{
"composition": [
{
"substance_name": "Acetone",
"cas_number": "67-64-1",
"ec_number": "200-662-2",
"index_number": null,
"reach_registration_number": null,
"concentration_percent": "95-100"
}
],
"transport_information": {
"un_number": "UN1090",
"modes": [ { "mode": "ADR", "hazard_class": "3", "packing_group": "II" } ]
}
}#Bulk jobs
Submit up to 10 documents at once. The call returns immediately and the work runs in the background.
POST/extract-sds/bulk
| Name | Type | Description |
|---|---|---|
file | file (repeat)required | One to 10 PDFs, each up to 10 MB and 40 MB in total. |
callback_url | stringoptional | An https:// URL that receives a signed webhook when the job finishes. Local and private addresses are rejected. |
language_hint | stringoptional | As for a single document. |
schema_version | stringoptional | As for a single document. |
Idempotency-Key | headeroptional | A unique value per job. Repeating the request with the same key returns the existing job instead of creating a duplicate. |
Credit for every file is reserved when the job is accepted. If the balance cannot cover all files the call returns 402 and nothing is processed. Files that fail are released and not billed. Bulk items return the JSON record only (no XML or CSV).
GET/extract-sds/bulk/{jobId}
Returns job progress and per-file results at any time. Use it to poll, or to recover results if a webhook never arrived. Jobs are visible to every key of the same account.
curl -X POST https://safetydatasheetapi.com/extract-sds/bulk \
-H "Authorization: Bearer $SDS_API_KEY" \
-H "Idempotency-Key: backfill-2026-10-25-batch-1" \
-F "[email protected]" \
-F "[email protected]" \
-F "callback_url=https://example.com/hooks/sds"
# later: poll the job
curl https://safetydatasheetapi.com/extract-sds/bulk/$JOB_ID \
-H "Authorization: Bearer $SDS_API_KEY"import os, time
import requests
headers = {"Authorization": f"Bearer {os.environ['SDS_API_KEY']}"}
job = requests.post(
"https://safetydatasheetapi.com/extract-sds/bulk",
headers={**headers, "Idempotency-Key": "backfill-2026-10-25-batch-1"},
files=[("file", open("acetone-sds.pdf", "rb")), ("file", open("ethanol-sds.pdf", "rb"))],
timeout=120,
)
job.raise_for_status()
job_id = job.json()["job_id"]
while True: # or use callback_url and skip polling
state = requests.get(f"https://safetydatasheetapi.com/extract-sds/bulk/{job_id}", headers=headers, timeout=30).json()
if state["status"] in ("completed", "failed"):
break
time.sleep(5)
for item in state["items"]:
print(item["file_name"], item["status"], item.get("confidence_score"))import { readFile } from "node:fs/promises";
const headers = { Authorization: `Bearer ${process.env.SDS_API_KEY}` };
const form = new FormData();
for (const name of ["acetone-sds.pdf", "ethanol-sds.pdf"]) {
form.append("file", new Blob([await readFile(name)], { type: "application/pdf" }), name);
}
const submit = await fetch("https://safetydatasheetapi.com/extract-sds/bulk", {
method: "POST",
headers: { ...headers, "Idempotency-Key": "backfill-2026-10-25-batch-1" },
body: form,
});
const { job_id } = await submit.json();
let state;
do {
await new Promise((resolve) => setTimeout(resolve, 5000)); // or pass callback_url and skip polling
state = await (await fetch(`https://safetydatasheetapi.com/extract-sds/bulk/${job_id}`, { headers })).json();
} while (!["completed", "failed"].includes(state.status));
for (const item of state.items) console.log(item.file_name, item.status, item.confidence_score);{
"ok": true,
"job_id": "b8b8e3b0-5a11-4d2c-9a57-0f1f6f0c2d77",
"status": "queued",
"total_items": 2,
"status_url": "https://safetydatasheetapi.com/extract-sds/bulk/b8b8e3b0-5a11-4d2c-9a57-0f1f6f0c2d77"
}{
"ok": true,
"job_id": "b8b8e3b0-5a11-4d2c-9a57-0f1f6f0c2d77",
"status": "completed",
"total_items": 2,
"completed_items": 2,
"failed_items": 0,
"webhook_status": "delivered",
"webhook_attempts": 1,
"items": [
{
"file_name": "acetone-sds.pdf",
"status": "success",
"confidence_score": 0.97,
"document_language": "en",
"schema_version": "1.0",
"warnings": [],
"data": { "product_identification": { "product_name": "Acetone" }, "...": "16 sections" }
}
]
}#Webhooks
Get notified when a bulk job finishes instead of polling.
When you pass callback_url, we POST the final job object (the same JSON as the status endpoint) to it. Delivery is attempted a few times immediately; it is not a persistent retry queue, so keep the status endpoint as the fallback.
Every delivery carries X-Webhook-Signature: sha256=<hex>, the HMAC-SHA256 of the raw request body using your webhook signing secret. The secret is specific to your account: show it in the dashboard under API keys ("Webhook signing secret"), keep it private, and never share it. Verify the signature before trusting the payload, and compare in constant time.
For replay protection, each delivery also carries X-Webhook-Id (unique per delivery), X-Webhook-Timestamp (Unix seconds) and X-Webhook-Signature-V2, the HMAC-SHA256 of {id}.{timestamp}.{raw body} with the same secret. Prefer V2: check it, reject timestamps older than five minutes, and ignore an id you have already processed.
import hashlib, hmac
def verify(secret: str, raw_body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header || "");
return a.length === b.length && timingSafeEqual(a, b);
}#Account and balance
Read your balance and recent usage with the same key.
GET/api/account
Returns the prepaid balance, the price, and the last 30 days of usage for the account that owns the key. It is read-only and free. The dashboard shows the same data with history and charts.
This endpoint needs a per-account key. It is not available with a shared enterprise key.
curl https://safetydatasheetapi.com/api/account \
-H "Authorization: Bearer $SDS_API_KEY"import os
import requests
account = requests.get(
"https://safetydatasheetapi.com/api/account",
headers={"Authorization": f"Bearer {os.environ['SDS_API_KEY']}"},
timeout=30,
).json()
print(account["balance_usd"], account["usage"]["documents_billed"])const account = await (
await fetch("https://safetydatasheetapi.com/api/account", {
headers: { Authorization: `Bearer ${process.env.SDS_API_KEY}` },
})
).json();
console.log(account.balance_usd, account.usage.documents_billed);{
"ok": true,
"balance_usd": 12.5,
"pricing": { "price_per_document_usd": 0.25, "pages_per_document": 40, "max_pages_per_request": 400 },
"usage": { "period_days": 30, "requests": 42, "succeeded": 41, "documents_billed": 44, "pages": 610, "spend_usd": 11 },
"daily": [ { "date": "2026-10-24", "documents": 3, "pages": 41, "spend_usd": 0.75 } ]
}#Pricing and billing
One price. Prepaid. You pay for results, not for failures.
- A longer document bills as one extra document for each further 40 pages, rounded up (45 pages = 2 documents). The limit is 400 pages.
- Credit is reserved before the extraction runs and released automatically if it fails. Provider errors, unreadable output and early stops are not billed.
- A low-confidence result is billed: the work was done. Use warnings and the score to decide what to review.
- When the balance cannot cover a request you get
402 insufficient_creditand nothing is processed. - Every response to a per-account key includes a
billingobject; usage is also listed in the dashboard.
Buying credit. Open Billing in the dashboard and choose a pack: $25 (100 documents), $100 or $500, priced in USD, GBP or EUR. Payment is by card on Stripe's page; prices exclude VAT, which is added where it applies, and businesses can enter a VAT number so it appears on the invoice. Every payment gets an invoice. Credit is valid for 12 months from purchase; whatever is unused then expires, and the dashboard warns you beforehand. Unused credit is refundable within 30 days and failed extractions are never charged: see the refund policy. Need a purchase order or a larger amount? Contact us.
{
"ok": false,
"error": { "code": "insufficient_credit", "message": "Not enough credit for this document. Add credit to continue." },
"balance_usd": 0.1,
"required_usd": 0.25,
"pages": 12,
"documents_billed": 1,
"request_id": "9a1f6c2e5b3d4e7f-AMS"
}#Rate limits
Per-key limits protect everyone's latency. Contracted plans can have different limits.
| Scope | Default limit |
|---|---|
POST /extract-sds per API key | 60 requests per minute |
POST /extract-sds/bulk per API key | 10 job submissions per minute |
| Shared enterprise key | 100 requests per 24 hours per client IP |
- Successful extraction responses include
X-RateLimit-RemainingandX-RateLimit-Reset(Unix seconds). - When limited you get
429 rate_limitedwith aRetry-Afterheader (seconds) andretry_after_epoch_secondsin the body. - Limits are enforced on a best-effort basis. Do not treat them as an exact quota boundary; the prepaid balance is the real spending control.
- For large backfills use bulk jobs rather than many parallel single requests.
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json
{"ok":false,"error":{"code":"rate_limited","message":"Upload limit reached for this time window."},"retry_after_epoch_seconds":1792764000}#Errors
Errors are JSON with a stable error.code. Branch on the code, not the message.
| Status | Code | Meaning | What to do |
|---|---|---|---|
400 | missing_file invalid_content_type invalid_multipart_payload | The request is not multipart/form-data with a file part. | Fix the request; do not retry unchanged. |
400 | unsupported_file_type | The upload is not a PDF. | Send the original PDF. |
400 | unsupported_schema_version | schema_version is not a supported version (only 1.0). | Send 1.0 or omit the field. |
401 | unauthorized api_key_revoked | The key is missing, invalid or revoked. | Check or replace the key in the dashboard. |
402 | insufficient_credit | The balance does not cover the document (for a bulk job: all documents). Nothing was processed or billed. | Add credit. Do not retry automatically. |
403 | account_suspended ip_restricted | The account is suspended, or the caller IP is blocked by the endpoint policy. | Contact support. |
413 | file_too_large job_too_large | A file exceeds 10 MB, or a bulk job exceeds 40 MB. | Split the job or compress the PDF. |
413 | too_many_pages | The PDF has more than 400 pages. Not billed. | Split the document. |
429 | rate_limited | The request limit for the window was reached. | Wait for the Retry-After seconds, then retry. |
500 / 502 | provider_error | The extraction model failed, returned unreadable output, or stopped early. Not billed. | Retry with exponential backoff. |
500 | output_processing_failed | Extraction ran but formatting the response failed. Not billed. | Retry; it is safe to repeat. |
503 | auth_unavailable billing_unavailable extract_api_not_configured | A dependency is temporarily unavailable. | Retry after a short delay. |
Some errors add fields beside error, for example balance_usd on a 402 or request_id on server errors. Retry only 429, 500, 502 and 503, with exponential backoff and jitter.
{
"ok": false,
"error": {
"code": "unsupported_file_type",
"message": "Only SDS/MSDS PDF files are supported."
}
}#AI agents and MCP
The same API is available to AI assistants through a remote MCP server.
Endpoint https://safetydatasheetapi.com/mcp (Streamable HTTP, stateless). Discovery works without a key; tool calls need an API key and bill to the same balance as REST calls.
| Tool | What it does |
|---|---|
extract_sds | Extract one PDF from a public file_url or file_base64. |
submit_bulk_extraction | Submit up to 10 PDFs by URL as one job. |
get_bulk_job | Read a job's status and results. Read-only. |
get_account | Read the balance and 30-day usage. Read-only. |
The server is listed in the official MCP registry as com.safetydatasheetapi/sds-extraction and described in mcp-manifest.yml. Give agents a spending allowance by funding a dedicated account; a 402 tells the agent to stop.
claude mcp add --transport http safetydatasheetapi https://safetydatasheetapi.com/mcp \
--header "Authorization: Bearer $SDS_API_KEY"{
"mcpServers": {
"safetydatasheetapi": {
"url": "https://safetydatasheetapi.com/mcp",
"headers": { "Authorization": "Bearer sds_live_..." }
}
}
}#Review routing
An extraction can succeed and still deserve a human look.
- Store the
request_idwith every result, even clean ones. - Route on warnings. Send any document with a non-empty
warningslist to a QA or regulatory queue. - Set a confidence floor that fits your risk, for example auto-accept above 0.9 and review below. The score is the model's own estimate, so tune the threshold on your own documents.
- Check
is_safety_data_sheetto catch wrong files early. - Validate what matters. CAS numbers have a checksum, and UN numbers are four digits; cheap checks catch most transcription errors.
- Pin
schema_versionso a future schema cannot change your mappings silently.
Extraction supports compliance work; it does not replace the responsible person's review of the source document.
#FAQ
What does the SDS extraction API return?
Each successful request returns a request ID, a confidence score, warnings, the detected document language, a fixed 16-section JSON record (schema version 1.0) and matching XML and CSV renderings built from the same extraction run.
How do I authenticate requests?
Send your API key as a bearer token (Authorization: Bearer sds_live_...) or in an X-API-Key header. Create and revoke keys in the dashboard.
How is the API billed?
Each document costs $0.25 for up to 40 pages; longer documents bill as one extra document per additional 40 pages. Credit is prepaid, bought by card in the dashboard (USD, GBP or EUR; VAT added where it applies), and valid for 12 months. Failed extractions are not billed.
When should I use bulk extraction and webhooks?
Use bulk jobs for backlogs, nightly refreshes and queued ingestion where a synchronous request per file would create unnecessary waiting. A webhook removes the need to poll.
How do low-confidence results surface?
Through confidence_score and warnings. The score is the model's own estimate, not a calibrated accuracy figure, so use it to route documents to review rather than as a guarantee.
Does the API support scanned and multilingual SDS PDFs?
The model reads the PDF directly and returns the detected document_language. Scan quality and unusual layouts affect confidence, which the warnings and score reflect.
How do schema versions protect integrations?
The response shape is fixed (version 1.0). Send schema_version to pin it; unsupported values are rejected instead of answered with a different shape. See the schema versioning guide.