The API

Everything in this guide describes buttons. This page describes the other way in — an HTTP interface for scripts, scheduled jobs, and assistants that read a specification sheet and file the result.

The promise it is built on is worth stating first, because it is the reason it exists in this shape rather than as a separate program: an endpoint performs exactly the act the corresponding button performs. The same rows, the same history line, the same emails to the same people. A colleague borrowing another laboratory’s shaker through a script produces a request that the owning laboratory’s admin cannot tell apart from one made on a phone, because both calls end in the same function. There is no second copy of any rule.

Who can use it

Administrators, for now. That is stricter than the interface, where taking equipment out is something anybody approved can do, and it is deliberate: it is easy to open a route to a lower tier later and hard to take one back once somebody’s script depends on it.

A token acts as the person it belongs to. Anything it does is recorded against their name, appears in the item’s history under their name, and is counted as theirs. This is not an anonymous service account and there is no way to make it one.

Getting a token

Admin ‣ API tokens. Give it a name that says what will be using it — nightly sync, label printer laptop — because the name is the only thing that will tell two of them apart in six months.

Two choices on the form:

Expires

Optional, and think before setting it. A token that expires on a Sunday night is a token that fails a job nobody is watching until Monday.

Read-only

The token can look at anything and change nothing, deletions included. If what you are building only reads — a dashboard, a stocktake, a report — take this. A credential that cannot delete a location is worth more than any amount of care at the point of deletion.

The token is shown once, on the page, immediately after you create it. The server keeps only a hash of it, so there is no second chance and no support request that can recover one: losing it means minting another and revoking the old. That is the point rather than an inconvenience — the database is backed up nightly onto the same machine that runs the password vault, and a backup that leaks should be a list of names, not a set of working credentials.

Using it

Send it as a bearer token:

curl -H "Authorization: Bearer lad_1a2b3c4d_…" \
     https://oprema.ladisk.si/api/v1/me

/api/v1/me is the call to make first. It changes nothing, and it answers whether the token works, who it acts as, and what it is allowed to do:

{
  "username": "mojca",
  "name": "Mojca Novak",
  "tier": "admin",
  "lab": "LADISK",
  "token": {"name": "nightly sync", "prefix": "1a2b3c4d",
            "read_only": false, "state": "active", "expires_at": null}
}

The full list of endpoints, with every argument and every response, is at https://oprema.ladisk.si/api/v1/docs. That page is generated from the code, so it cannot drift from what the server actually does. This page is the part a generated document has never been able to explain: why a request was refused.

Reading the catalogue

GET /api/v1/equipment lists it, and takes the filters the All filters page takes: q, category, lab, tag, home, usage, status.

Two things about it are worth knowing before you write a script against it.

Filters narrow, they never widen. Two tag parameters mean equipment carrying both tags, not either. That is the opposite of most search boxes and it is deliberate: narrowing to one shelf is the thing people actually do here, and a filter that widened would make it impossible.

The search is the same search. Not “behaves the same” — it is literally the same code the front page runs, so a script and a colleague looking over your shoulder see the same rows for the same words. It matches the manufacturer, the model, the serial number, the notes, the identifier, and whichever parameter the category nominates in place of a serial number. A result that differed between the two would not read as a bug; it would read as equipment having gone missing.

Retired equipment is left out unless you ask for it with include_inactive. Its QR label still resolves, because a sticker on a shelf outlives the decision to retire the thing it is on.

The listing is paged — limit and offset, with a count beside the items. So is /users. /signups and /locations are not, and return a plain array: both are bounded by how many people work here and how many rooms there are, and wrapping eight rows in a page object would be ceremony. A row carries what a listing needs and no more; follow url, or ask for GET /api/v1/equipment/{public_id}, for the specifications, the tags, who has it, what it is booked for and its history. Identifiers printed as LAD- before August 2026 resolve there too.

GET /api/v1/users reports both tier and role, and they are not the same answer. tier is what is stored and what PATCH accepts — user, manager or admin. role is what the interface calls somebody: a person with a mentor reads as student without that changing anything they may do, and a superuser reads as superuser, which this app can report and cannot grant.

Moving a due date

POST /api/v1/equipment/{public_id}/due with {"due": "2026-10-02"} changes when an open loan is expected back — the one answer on a checkout that is a forecast rather than a record of what happened, and so the only one that can be corrected without describing something that did not take place.

This is what makes a weekly sweep worth writing. An overdue list is only worth reading while its dates are ones somebody still means to keep, and before this the only way to move one was to check the equipment in and out again.

{"due": null} clears the date, which is a real answer rather than an omission: somebody who no longer knows when a thing is coming back should be able to say so. Note that this differs from PATCH, where a field you leave out is a field you did not want to change — here the body has one field, so leaving it out could not mean anything else.

Three refusals worth branching on. A date in the past is a 422: bringing the equipment back in is what that means, not a date. An item nobody has out is a 404, so a loan that ended between your reading it and your writing does not quietly get a date. And a loan that is not your business is a 403 — only the holder, whoever took it out for them, that person’s mentor and a manager may move it, the same four people the page allows.

Sending the date it already has answers 200 with changed: false.

GET /api/v1/locations/{home|usage} carries in_use beside each place: how many rows point at it. That is the number a deletion would rewrite, and the number a delete request has to echo back — it is returned here so that checking before deleting does not cost a call per location.

When it says no

Four answers, and they mean different things:

401

No token, or one this server does not recognise. Check the header and that the token has not been revoked.

403

A real token that may not do this. Either its owner is no longer an administrator, or the token is read-only and the request would change something. Retrying will not help.

422

The request was understood and refused on its merits — the item is already out, the laboratory has not agreed to lend it, the person is a superuser. The body carries a stable error slug to branch on and a detail sentence written for a human. This one may well succeed tomorrow.

404

No such item, plan, location, category or person. The body has the same shape as every other refusal, with "error": "not_found".

409

Only from destructive endpoints, and it means the world is not in the state you thought. See below.

Deleting things

Anything irreversible takes three precautions, and none of them is decoration.

A read-only token is turned away before the request is even parsed.

Every destructive endpoint accepts ?dry_run=1. It returns the identical response it would have returned, with "applied": false, and writes nothing. Use it first, always, and especially from anything with a language model in the loop.

You have to say what you expect to happen. Deleting a location rewrites every checkout or item that pointed at it, repoints anybody whose default it was, and leaves a tombstone that suppresses the spelling in all future imports — three side effects, none visible afterwards. So the request carries the number of rows you believe it will touch:

curl -X DELETE -H "Authorization: Bearer …" \
     -H "Content-Type: application/json" \
     -d '{"move_to": "shelf-2", "confirm_affected": 12}' \
     https://oprema.ladisk.si/api/v1/locations/home/old-cupboard

If it is not twelve, nothing happens and you get a 409 naming the real number. There has to be a move_to whenever anything points at the place — history is never deleted, so those rows need somewhere to go. A place nothing points at can be retired with confirm_affected: 0 and no target, and then the tombstone is the point: the spreadsheet stops recreating a spelling somebody deliberately got rid of. This is the same guarantee the confirmation page gives when it shows you the count before you press the button, and it catches the case that actually hurts: your picture of the world went stale between reading and deciding.

Experiments

The planner over HTTP, under /api/v1/experiments. This is the one place in the API where you send a database id rather than something printed on a thing — an experiment has no label and no slug, its name is whatever its owner last called it, and two people may reasonably run a “Modal test” in the same week. The id comes back from POST /api/v1/experiments and from the listing.

POST /api/v1/experiments starts one: a name, starts_on, ends_on, an optional usage_location slug and an optional holder username for somebody you supervise. POST …/{id}/plan says what it needs — {"category": "accel", "count": 3} becomes three separate lines, so a different sensor can be chosen for each.

Choosing claims, and the response says how. POST …/{id}/plan/{entry}/choose with {"equipment": "LA-0007"} answers with an action:

checked_out

The window is running, so the equipment is out — to the experiment’s room, due back on its end date. The same loan the button makes.

booked

The window is still ahead, so the days are claimed instead. One booking per experiment, under its name, collected as a batch when the window opens.

needs_blessing

The equipment belongs to another laboratory that has not agreed yet. The choice is recorded and nothing is taken and nothing is emailed — see below.

chosen

The window has already passed, so there were no days left to claim.

Ask for another laboratory’s equipment once, not per item. POST …/{id}/ask files a request for every line still waiting, and sends one email per owning laboratory listing what it is being asked for. Ten sensors from one lab is one email to its administrators, which is the whole reason this is a separate call rather than something choosing does. Call it after choosing everything, not between choices.

POST …/{id}/collect takes out everything chosen and not yet out; POST …/{id}/close brings back everything still out under it. Both are per-item internally: one refusal comes back in warnings and the rest still happens, because one accelerometer from a laboratory that has not answered must not stop the other five going out.

DELETE …/{id}/plan/{entry} removes the choice first and leaves the line asking for its category; a second call removes the bare line. A line whose equipment is still out is refused — check it in first, because tidying a plan must never lose track of hardware.

DELETE …/{id} destroys the plan and takes all three precautions below. It does not return equipment: the checkouts stop naming the experiment and are otherwise untouched, and confirm_affected is how many of them there are.

Printing labels

GET /api/v1/labels/sheet.pdf returns the same A4 sheet the picker produces, from the same parameters: size, category repeated for each one you want, home_location, unlabelled, and — for an explicit list — either id repeated or ids as one comma-separated string. An explicit list wins over the filters.

unlabelled=1 keeps only equipment whose has_label is false — the ones nobody has stuck a printed sticker on yet. Nothing infers that field: rendering a sheet says a label was drawn, not that somebody cut it out and put it on a shelf, so it is set by a person or by PATCH /api/v1/equipment/{public_id}. Together those two are the labelling round trip a script can actually run — print what is outstanding, then mark what you stuck on. It narrows the categories rather than replacing them, and like home_location it is ignored when identifiers are given.

An identifier that matches no active equipment is a 422 unknown_id naming the ones that failed. It is refused rather than dropped because a sheet of seven labels looks exactly like a sheet that was meant to have seven.

size is small, large, or custom — and custom takes five more in millimetres: w and h for the whole label, gap between labels, and top and left for where the first one starts. That last pair is what makes pre-cut stock work: the grid begins exactly there instead of being centred. A geometry that cannot make a readable label is a 422 naming what is wrong with it.

The reason it exists is the pass that follows filing: a box of new sensors created through the API, then labelled in the same script, without anybody opening a page. X-Label-Count on the response says how many actually went on the sheet — worth reading, because a selection larger than 400 is capped and a count that is exactly 400 means there were more.

An empty selection is a 404 rather than a one-page PDF saying nothing matched, because the latter is a thing a script would cheerfully print.

This is the one endpoint whose cost is the problem rather than how often it is called — four hundred QR symbols through a PDF library is seconds of processor time — so it has a budget of its own, six a minute, counted separately from reads. A read-only token may print: producing a sticker changes nothing.

How much you can ask for

This server is small and it is not only ours — a conference site runs beside it that people rely on being up. So there is a ceiling, and it is set to be invisible to anything sensible and obvious to a loop.

Per token, per minute: 300 reads, 120 writes and 6 label sheets.

Reads and writes are counted separately, so a batch — checking in everything in one room, filing a box of new sensors — runs straight through even if something is listing beside it. And all of it is counted per token rather than per person, so a script that has gone wrong stops without spending the budget of the one running next to it, and revoking the offender fixes it without touching anybody else.

The numbers are chosen so that nothing anybody actually does ever meets one. A person, a script or an assistant working through a job makes a few requests a second at the very most; a runaway makes hundreds. A limit tight enough to “feel safe” would trip an assistant halfway through filing a box of sensors, which protects nothing and leaves a half-finished job and a colleague who goes back to the spreadsheet. If it turns out something legitimate does need more room, every one of these is an environment variable — say so and it is a restart, not a release.

Over the limit is 429 with a Retry-After header. Wait the number of seconds it names; retrying immediately just spends the next window.

A single request is bounded too. limit above 200 is refused, not quietly reduced — a caller who asked for five thousand and silently got two hundred would page through the rest believing they had it all.

If you are polling for something, poll slowly. Nothing in this catalogue changes faster than a person can walk to a shelf, and a minute between calls is plenty for anything short of a live display.

Pointing an assistant at it

GET /api/v1/llms.txt is the whole API on one page, written for a machine to act on rather than for a person to browse: the rules a schema cannot state, the status codes with what to do about each, and worked examples of the two flows that matter — filing new equipment, and taking something out.

Hand it to an assistant once at the start of a session and it needs nothing else. The endpoint inventory in it is generated from the same schema the server serves, so it cannot come to disagree with the routes; the prose around it is written by hand, because why a request will be refused is not in any schema.

It needs a token like everything else. That is a departure from the llms.txt convention, which puts the file at the site root where anything can read it — this one is a map of every route, argument and safeguard here, and this app sits behind a login for the same reason a shelf of borrowed equipment does.

Answering the queue, and changing accounts

POST /api/v1/signups/{id}/approve and /decline do exactly what the two buttons do, email included. Both take an optional comment, and it is worth using on a decline: it is the only thing the applicant is told beyond the answer. Approving somebody already approved is not an error and not a repeat — it answers changed: false, keeps the original approver and timestamp, and sends no second email, because a retried request has to be able to arrive twice.

PATCH /api/v1/users/{id} changes tier, lab, mentor, blocked and show_vault_link. Only the fields you send are touched, and mentor is the one to be careful with: sending null clears it, leaving it out leaves it alone. Those are different requests and the difference matters — promoting somebody out of supervision is one of them, and a script that forgot a field is the other.

The refusals are the same ones the interface has, for the same reasons: nobody changes their own role, a superuser is Django’s own flag and not this app’s to grant, a mentor has to be a manager or above, and a mentor with students cannot be demoted until they are reassigned.

Fields are applied in order and the first refusal stops the request — what was applied before it stands. These are separate saves and reporting otherwise would be a lie about what happened. Send one field at a time if that matters to you.

Adding and retiring places

POST /api/v1/locations/{home|usage} adds a name, PATCH renames one.

Renaming leaves the old spelling pointing at the same place. That is not politeness, it is necessary: the spreadsheet keeps its own spellings forever, so a tidy-up that left no note behind would be undone by the next nightly import. Adding back a name that was previously retired revives its alias for the same reason — it is a deliberate reversal, so anything remembered as resolving elsewhere resolves here again.

DELETE is the sharpest thing here and is covered under Deleting things below.

Rotating a token

Rotate issues a replacement and leaves the old one working for 24 hours. That window is the entire point. Without it, rotating means a moment where the new secret is not yet wherever the old one was stored, and whatever was using it fails — so nobody rotates, which is the thing rotation exists to prevent.

So the safe order is: rotate, copy the new secret into wherever the old one lives, confirm the job runs, and let the old one lapse on its own. A token in that window shows as Rotated in the list.

A token can also rotate itself through the API, which is what lets an unattended job keep its own credential fresh. It cannot mint a new one or touch anybody else’s: a token that can create tokens is a permanent administrator with a convenient interface on it.

Revoking

Revoke stops a token immediately and permanently. Do it when a laptop is lost, when a script is retired, or whenever you are not sure — minting another takes ten seconds.

Three other things stop a token without anybody revoking it, which is worth knowing when one suddenly fails:

  • it reached its expiry date;

  • it was rotated more than a day ago;

  • its owner was blocked or dropped below Admin. A token is not a permission of its own. Somebody shut out of the app is shut out of the API in the same instant, and nobody has to remember to do it.