Developers
API & webhooks
Your lab's records are yours. Read them from a script, and have LevelSixLabs tell your other systems when something happens here — a fault reported, work stopped at the bench, stock running out. REST over HTTPS, JSON in and out, signed webhooks.
Create keys and webhooks under Settings → API & webhooks. You'll need the Settings “manage” permission.
Authenticating
Send your key as a bearer token. Keys start lsl_sk_ and are shown once, when you create them — we store only a hash, so we can't recover one for you. If you lose it, revoke it and make another.
curl https://levelsixlabs.com/api/v1/chemicals \
-H "Authorization: Bearer lsl_sk_your_key_here"GET /api/v1 returns what your key can reach, which is the quickest way to debug a 403 — it lists exactly the resources and scopes that key holds.
Scopes
A key holds module:action scopes, using the same vocabulary as in-app permissions. Write includes read — the reverse never applies.
- No delete, anywhere. Deletion is unrecoverable and no integration has needed it. Archive through a
PATCHinstead. - No access to your member list. People aren't inventory; a leaked key should never become a personal-data breach.
- No approving or verifying. Those are human judgements — a script asserting one is a script forging a signature.
| Scope | Grants |
|---|---|
| chemicals:read | Read chemicals |
| chemicals:write | Add and update chemicals, log disposals |
| equipment:read | Read equipment |
| equipment:write | Add equipment, log services and faults |
| bookings:read | Read bookings |
| bookings:write | Create and cancel bookings |
| spaces:read | Read rooms & spaces |
| spaces:write | Add and update rooms |
| samples:read | Read samples |
| samples:write | Register samples, move and discard them |
| inventory:read | Read inventory |
| inventory:write | Receive stock and log consumption |
| sops:read | Read sops |
| sops:write | Create and update controlled documents |
| assets:read | Read assets & visitors |
| assets:write | Add documents and visitor records |
| settings:read | Read settings |
| settings:write | Read lab configuration |
| risk_assessments:read | Read risk assessments & coshh |
| risk_assessments:write | Read assessments — creation stays in the app, where sign-off lives |
| training:read | Read training records |
| training:write | ⚠️ Includes who holds which competency, and when it expires |
Resources
Every collection supports GET /api/v1/<resource> and GET /api/v1/<resource>/<id>. Some also accept POST and PATCH — the rest are read-only because creating them has to run business rules that live in the app. A booking, for instance, needs the overlap check, the training gate and the fault block; a generic insert would bypass all three and double-book an instrument.
| Resource | Scope | Writable | Filters |
|---|---|---|---|
| /chemicals | chemicals | Yes | storage_location, manufacturer, cas_number, room_id |
| /equipment | equipment | Yes | status, location, room_id |
| /bookings | bookings | Read-only | status, equipment_id, room_id |
| /rooms | spaces | Read-only | building_id |
| /samples | samples | Yes | status, sample_type, project, box_id |
| /stores | inventory | Read-only | kind, archived |
| /store-products | inventory | Read-only | store_id, item_id, archived |
| /inventory-items | inventory | Read-only | supplier, archived, collection_id |
| /waste | chemicals | Read-only | waste_source, is_hazardous, ewc_code |
| /faults | equipment | Read-only | status, severity, equipment_id |
| /maintenance-jobs | equipment | Read-only | status, priority, equipment_id |
| /service-records | equipment | Yes | equipment_id, service_type |
| /risk-assessments | risk_assessments | Read-only | status, assessment_type |
| /sops | sops | Read-only | status, category |
| /training-records | training | Read-only | user_id, equipment_id |
| /labs | spaces | Read-only | — |
| /parts | equipment | Read-only | equipment_id, part_type, fitment_status, status |
| /programmes | equipment | Read-only | equipment_id, category, status |
| /incidents | risk_assessments | Read-only | status, severity, kind |
Resources marked with a plan requirement return 402 if the lab's plan doesn't include them — the API can't hand out what the app wouldn't show.
Fields, per resource
Exactly what each collection returns. This is an allow-list, not a dump of the table — columns are added to it on purpose, so a field we add internally never starts leaving the building without somebody deciding it should. Fields marked writable may also be sent on POST and PATCH.
/api/v1/chemicals
Scope chemicals:read · write with chemicals:write · deleted records are left out unless you ask for them
- id
- created_at
- namewritablerequired
- cas_numberwritable
- formulawritable
- manufacturerwritable
- catalog_numberwritable
- lot_numberwritable
- quantitywritable
- unitwritable
- statewritable
- storage_locationwritable
- storage_tempwritable
- hazard_codes
- signal_word
- received_datewritable
- expiry_datewritable
- opened_datewritable
- low_stock_thresholdwritable
- noteswritable
- updated_at
- tags[] · filter with ?tag=
/api/v1/equipment
Scope equipment:read · write with equipment:write · deleted records are left out unless you ask for them
- id
- created_at
- namewritablerequired
- manufacturerwritable
- modelwritable
- serial_numberwritable
- asset_tagwritable
- statuswritable
- locationwritable
- requires_training
- is_surplus
- purchase_datewritable
- warranty_endwritable
- noteswritable
- tags[] · filter with ?tag=
/api/v1/bookings
Scope bookings:read · read-only · needs the bookings plan feature
- id
- created_at
- equipment_id
- room_id
- user_id
- starts_at
- ends_at
- status
- purpose
- project_code
/api/v1/rooms
Scope spaces:read · read-only · needs the bookings plan feature
- id
- created_at
- name
- building_id
- capacity
- description
- tags[] · filter with ?tag=
/api/v1/samples
Scope samples:read · write with samples:write · needs the samples plan feature
- id
- created_at
- namewritablerequired
- barcodewritable
- sample_typewritable
- status
- box_id
- position_row
- position_col
- volumewritable
- volume_unitwritable
- concentrationwritable
- concentration_unitwritable
- quantity
- organismwritable
- sourcewritable
- projectwritable
- parent_sample_id
- collected_atwritable
- frozen_at
- expiry_datewritable
- freeze_thaw_count
- consent_statuswritable
- updated_at
- tags[] · filter with ?tag=
/api/v1/stores
Scope inventory:read · read-only · needs the inventory plan feature · archived records are left out unless you ask for them
- id
- created_at
- updated_at
- name
- kind
- ordering_url
- lead_time_days
- collection_point
- archived
/api/v1/store-products
Scope inventory:read · read-only · needs the inventory plan feature · archived records are left out unless you ask for them
- id
- created_at
- updated_at
- store_id
- item_id
- product_name
- order_code
- product_url
- pack_size
- pack_unit
- pack_noun
- list_price
- price_checked_on
- is_preferred
- archived
- tags[] · filter with ?tag=
/api/v1/inventory-items
Scope inventory:read · read-only · needs the inventory plan feature · archived records are left out unless you ask for them
- id
- created_at
- name
- catalogue_number
- supplier
- description
- default_unit
- low_stock_threshold
- archived
- updated_at
- tags[] · filter with ?tag=
/api/v1/waste
Scope chemicals:read · read-only
- id
- created_at
- chemical_name_snapshot
- waste_source
- quantity
- unit
- disposal_method
- disposal_date
- ewc_code
- is_hazardous
- physical_form
- consignment_id
- tags[] · filter with ?tag=
/api/v1/faults
Scope equipment:read · read-only
- id
- created_at
- equipment_id
- room_id
- title
- description
- severity
- status
- occurred_at
- blocks_bookings_until
- resolved_at
/api/v1/maintenance-jobs
Scope equipment:read · read-only
- id
- created_at
- updated_at
- title
- description
- location
- priority
- status
- equipment_id
- room_id
- fault_report_id
- assigned_to
- resolved_at
/api/v1/service-records
Scope equipment:read · write with equipment:write
- id
- created_at
- equipment_idwritablerequired
- service_typewritable
- performed_bywritable
- performed_atwritablerequired
- next_service_duewritable
- certificate_numberwritable
- resultwritable
- noteswritable
/api/v1/risk-assessments
Scope risk_assessments:read · read-only · needs the risk_assessments plan feature
- id
- created_at
- title
- reference
- assessment_type
- status
- version
- containment_level
- review_due
- last_reviewed_at
- approved_at
- updated_at
- tags[] · filter with ?tag=
/api/v1/sops
Scope sops:read · read-only · needs the sops plan feature
- id
- created_at
- title
- reference
- category
- description
- status
- current_version_id
- equipment_id
- review_interval_months
- next_review_date
- updated_at
- tags[] · filter with ?tag=
/api/v1/training-records
Scope training:read · read-only
- id
- created_at
- user_id
- equipment_id
- topic
- trained_on
- trained_by
- expires_on
/api/v1/labs
Scope spaces:read · read-only · needs the bookings plan feature
- id
- created_at
- name
- lead_user_id
- description
- tags[] · filter with ?tag=
/api/v1/parts
Scope equipment:read · read-only · needs the parts plan feature
- id
- created_at
- equipment_id
- role
- part_type
- manufacturer
- part_number
- description
- quantity_per_change
- unit
- fitment_status
- fit_type
- fitment_source_url
- replace_interval_months
- last_replaced_at
- next_due_date
- replace_on_condition
- status
- notes
- updated_at
- tags[] · filter with ?tag=
/api/v1/programmes
Scope equipment:read · read-only · needs the programmes plan feature
- id
- created_at
- reference
- name
- category
- instrument_type
- equipment_id
- description
- status
- current_version_id
- sop_id
- next_review_date
- updated_at
- tags[] · filter with ?tag=
/api/v1/incidents
Scope risk_assessments:read · read-only · needs the risk_assessments plan feature
- id
- created_at
- reference
- kind
- severity
- status
- occurred_at
- room_id
- equipment_id
- location_note
- risk_assessment_id
- sop_id
- due_on
- riddor_reportable
- riddor_reported_at
- closed_at
- tags[] · filter with ?tag=
Filtering & pagination
Every list accepts limit (default 50, max 200), offset, and since / until against the resource's own sort column — so “what changed?” works without you having to guess which column that is.
curl "https://levelsixlabs.com/api/v1/faults?status=open&limit=100" \
-H "Authorization: Bearer lsl_sk_..."
{
"data": [ { "id": "…", "title": "Fume hood alarm", "severity": "high", … } ],
"pagination": { "limit": 100, "offset": 0, "has_more": false }
}There is no total count: it costs an extra aggregate on every request and almost nobody uses it. has_more is what a pager needs.
Rate limits & errors
1000 requests per key per hour. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining so you can back off before we tell you to.
Errors are always { "error": { "code", "message" } }. Branch on code, never on the prose — we reserve the right to improve the wording.
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_key / invalid_key | No key, or the key isn't valid, revoked or expired |
| 402 | plan_required | The lab's plan doesn't include this resource |
| 403 | insufficient_scope | Valid key, wrong scopes — the response names the one needed |
| 403 | organisation_inactive | The lab's account isn't active |
| 404 | not_found | No such record, or it isn't yours |
| 405 | read_only | This resource can't be written via the API |
| 429 | rate_limited | Over the hourly limit |
A record in another lab and a record that doesn't exist both return 404. Telling them apart would confirm that an id exists somewhere.
Webhooks
Add an endpoint in Settings, pick the events you care about, and we POST JSON to it. We retry 5 times over about a day (1, 5, 30, 120, 600 minutes after each failure), then mark it failed and show it in the endpoint's history. A 4xx other than 408 or 429 isn't retried — you've told us no, and retrying for a day only fills your error log.
{
"event": "equipment.fault_reported",
"occurred_at": "2026-08-12T09:41:02.511Z",
"organisation_id": "…",
"delivery_id": "…",
"data": { "fault_id": "…", "title": "Fume hood alarm", "severity": "high" }
}delivery_id is stable across retries — dedupe on it and a retried delivery won't look like a second event.
Verifying the signature
Every request carries LevelSixLabs-Signature: t=<unix>,v1=<hex>. The HMAC covers "<t>.<raw body>" — the timestamp is inside the signed string, so a genuine payload can't be replayed at you later. Reject anything older than 300 seconds.
import crypto from "node:crypto";
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 2))
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1)
);
}Use the raw body, before any JSON parsing — re-serialising changes the bytes and the signature won't match. During a secret rotation we send two v1= values; accept either.
Events
| Event | Sent when |
|---|---|
| powra.stopped | A point-of-work check ended in a stop. The most urgent thing we can tell you. |
| equipment.fault_reported | An instrument or room has been flagged as not fit to use |
| equipment.service_due | An instrument has passed, or is approaching, its next service date |
| maintenance.job_raised | Somebody asked for work to be done |
| maintenance.job_completed | Work finished, and a service record filed if it was an instrument |
| booking.created | Somebody booked an instrument or a room |
| booking.cancelled | A booking was cancelled, including automatically by a fault |
| chemical.expiring | A chemical has reached, or passed, its expiry date |
| chemical.disposed | A disposal was logged — chemical, consumable or sample |
| inventory.low_stock | An item dropped to or below its reorder threshold |
| risk_assessment.review_due | An assessment has reached its review date |
| risk_assessment.approved | An assessment completed its signature chain |
| sample.discarded | Stored material was discarded |
Endpoints must be public https. We don't follow redirects and won't call private network addresses — both would turn a webhook into a way to make our servers fetch things on your behalf.
Outlook & calendar
There is no Outlook write-sync, and we'd rather say so than leave a dead toggle in the settings. Two-way calendar sync needs an organisation-level Microsoft app registration that we don't operate.
What works instead, today, in Outlook, Google Calendar and Apple Calendar: a read-only subscription feed. Copy the ICS URL from your bookings page and your calendar keeps itself up to date. It's the honest answer to “does it work with Outlook?” — yes, for seeing bookings; no, for creating them from Outlook.