Skip to content

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 PATCH instead.
  • 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.
ScopeGrants
chemicals:readRead chemicals
chemicals:writeAdd and update chemicals, log disposals
equipment:readRead equipment
equipment:writeAdd equipment, log services and faults
bookings:readRead bookings
bookings:writeCreate and cancel bookings
spaces:readRead rooms & spaces
spaces:writeAdd and update rooms
samples:readRead samples
samples:writeRegister samples, move and discard them
inventory:readRead inventory
inventory:writeReceive stock and log consumption
sops:readRead sops
sops:writeCreate and update controlled documents
assets:readRead assets & visitors
assets:writeAdd documents and visitor records
settings:readRead settings
settings:writeRead lab configuration
risk_assessments:readRead risk assessments & coshh
risk_assessments:writeRead assessments — creation stays in the app, where sign-off lives
training:readRead 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.

ResourceScopeWritableFilters
/chemicalschemicalsYesstorage_location, manufacturer, cas_number, room_id
/equipmentequipmentYesstatus, location, room_id
/bookingsbookingsRead-onlystatus, equipment_id, room_id
/roomsspacesRead-onlybuilding_id
/samplessamplesYesstatus, sample_type, project, box_id
/storesinventoryRead-onlykind, archived
/store-productsinventoryRead-onlystore_id, item_id, archived
/inventory-itemsinventoryRead-onlysupplier, archived, collection_id
/wastechemicalsRead-onlywaste_source, is_hazardous, ewc_code
/faultsequipmentRead-onlystatus, severity, equipment_id
/maintenance-jobsequipmentRead-onlystatus, priority, equipment_id
/service-recordsequipmentYesequipment_id, service_type
/risk-assessmentsrisk_assessmentsRead-onlystatus, assessment_type
/sopssopsRead-onlystatus, category
/training-recordstrainingRead-onlyuser_id, equipment_id
/labsspacesRead-only
/partsequipmentRead-onlyequipment_id, part_type, fitment_status, status
/programmesequipmentRead-onlyequipment_id, category, status
/incidentsrisk_assessmentsRead-onlystatus, 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.

StatusCodeMeaning
401missing_key / invalid_keyNo key, or the key isn't valid, revoked or expired
402plan_requiredThe lab's plan doesn't include this resource
403insufficient_scopeValid key, wrong scopes — the response names the one needed
403organisation_inactiveThe lab's account isn't active
404not_foundNo such record, or it isn't yours
405read_onlyThis resource can't be written via the API
429rate_limitedOver 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

EventSent when
powra.stoppedA point-of-work check ended in a stop. The most urgent thing we can tell you.
equipment.fault_reportedAn instrument or room has been flagged as not fit to use
equipment.service_dueAn instrument has passed, or is approaching, its next service date
maintenance.job_raisedSomebody asked for work to be done
maintenance.job_completedWork finished, and a service record filed if it was an instrument
booking.createdSomebody booked an instrument or a room
booking.cancelledA booking was cancelled, including automatically by a fault
chemical.expiringA chemical has reached, or passed, its expiry date
chemical.disposedA disposal was logged — chemical, consumable or sample
inventory.low_stockAn item dropped to or below its reorder threshold
risk_assessment.review_dueAn assessment has reached its review date
risk_assessment.approvedAn assessment completed its signature chain
sample.discardedStored 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.

Questions, or need an endpoint we don't expose? Get in touch. The API is versioned — /api/v1 won't change shape under you.