Lendra API, as shipped in the download
Lendra API (v2.0)
Put the checkout desk anywhere
A tablet by the door, a barcode scanner, a Slack shortcut, a workshop kiosk — anything that can POST JSON can now issue and return equipment, and **every rule the counter enforces applies identically**. The API calls the same Checkout::checkOut() the form does, so it cannot be the laxer path.
curl -X POST https://lendra.example.com/api/checkouts \
-H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
-d '{"asset_id": 12, "borrower_id": 4, "due_date": "2026-08-20"}'
Omit due_date and it uses your configured loan length, exactly like the form.
The guards you get for free
| Situation | Response |
|---|---|
| Asset is already checked out | 409 — "That asset is already checked out. Check it in first." |
| Asset is flagged for maintenance / retired | 409 with the specific reason |
| Borrower is inactive | 409 — "That borrower is inactive — reactivate them first." |
| Due date is in the past | 409 — "a loan cannot start overdue" |
| Two requests race for the same item | One wins; the other gets 409 |
That last row matters if you wire up more than one kiosk. The check-out is a single atomic UPDATE … WHERE status = 'available': if it changes no rows, someone else got there first and the loan is refused rather than double-issued. The API inherits this because it does not implement availability at all — it delegates.
Returning
curl -X POST https://lendra.example.com/api/checkouts/88/return \
-H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
-d '{"condition_in": "good", "note": "battery flat"}'
Returning a loan twice is refused with 409 — "That item was already checked in." — so a retried request or a double-tapped kiosk button cannot corrupt the history. A return with "condition_in": "damaged" sends the asset to maintenance rather than back to available.
Overdue
curl -H "Authorization: Bearer apk_xxxx" https://lendra.example.com/api/overdue
{
"overdue": [
{"id": 88, "asset_name": "Impact driver", "asset_tag": "TAG-1",
"borrower_name": "R. Vance", "due_at": "2026-08-13 23:59:59",
"state": "overdue", "overdue": true, "days_overdue": 7}
],
"count": 1,
"as_of": "2026-08-20 09:00:00"
}
state is out · overdue · returned, and it is **computed from due_at versus now on every request** — nothing stores it. Lendra runs no cron, so there is no nightly job that could leave you with a stale list; as_of tells you exactly which instant the answer describes.
Authentication
Create a key in Settings → API & webhooks. It is shown once.
Authorization: Bearer apk_<40 hex chars>
/api/* is CSRF-exempt by design: the key is the credential and browsers never send it implicitly.
Endpoints
| Method | Path | Notes | |||
|---|---|---|---|---|---|
| GET | /api/ping | Verify a key | |||
| GET | /api/assets | `?status=available\ | checked_out\ | maintenance\ | retired` |
| GET | /api/borrowers | Everyone who can borrow | |||
| GET | /api/checkouts | `?state=out\ | overdue\ | returned` (computed) | |
| POST | /api/checkouts | Check an item out | |||
| POST | /api/checkouts/{id}/return | Check it back in | |||
| GET | /api/overdue | The overdue list, computed on request | |||
| GET | /api/openapi.json | OpenAPI 3.0.3 — imports as a custom connector | |||
| GET | /healthz | Unauthenticated {"ok":true,"app":"lendra","version":"2.0.0"} |
Every asset carries "available": true|false from the product's own availability rule, so a kiosk can grey out what it cannot issue without guessing at status strings.
Webhooks
| Event | Fires when |
|---|---|
item.checked_out | An item goes out — at the counter, over the API or through an agent |
item.returned | An item comes back — at the check-in page, the desk's one-click return, over the API or through an agent (from 3.1.4; before, an agent's return fired nothing) |
Signed with your per-endpoint secret:
X-Lendra-Event: item.checked_out
X-Lendra-Signature: sha256=<hmac_sha256(raw_body, secret)>
import hmac, hashlib
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-Lendra-Signature"]):
abort(401)
One attempt per event with a 5-second timeout — a slow receiver must never make someone wait at the counter. The last 200 deliveries per endpoint are logged with their response code.
There is no item.overdue webhook, deliberately. Going overdue is a due date passing, not an event anything in Lendra observes; firing it would need a background job, and Lendra runs none. Poll /api/overdue on your own schedule — same answer, computed fresh, your cadence.
Errors
| Code | Meaning |
|---|---|
401 unauthorized | Missing, malformed, revoked or unknown key |
404 not_found | No such checkout |
409 conflict | Refused by a guard — detail is the same sentence the counter shows |
422 validation | Missing or malformed input |
MCP — the agent endpoint (new in 3.0)
Lendra speaks MCP (Model Context Protocol) on one route, so an assistant can answer "who has the projector?" and lend or take back an item without anyone building a bridge first.
POST /mcp
Authorization: Bearer apk_... ← the SAME revocable key the REST API uses
Content-Type: application/json
Stateless streamable-HTTP: one JSON-RPC message per request, no session, no SSE. GET /mcp returns 405 with Allow: POST, by design.
Connect it to an assistant
Mint the key in the app first: Settings → API keys. Choose Read only when the assistant should answer questions but never change anything — the endpoint then lists only the read tools and refuses the rest by name, so a careless prompt cannot write. Full access behaves as before.
Every client needs the same three facts, and nothing in the handshake is vendor-specific:
| The address | https://lendra.example.com/mcp |
| The key | header Authorization: Bearer apk_xxxx |
| The transport | MCP over streamable HTTP, stateless |
Claude — one command, or the same URL and header as a custom connector in the desktop and web apps:
claude mcp add --transport http lendra https://lendra.example.com/mcp \
--header "Authorization: Bearer apk_xxxx"
ChatGPT and the OpenAI API — one entry in the Responses API's tools array (in ChatGPT itself, the same URL and key go in as a connector):
{
"type": "mcp",
"server_label": "lendra",
"server_url": "https://lendra.example.com/mcp",
"authorization": "apk_xxxx",
"require_approval": "never"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "lendra", "name": "Lendra", "url": "https://lendra.example.com/mcp",
"token": "apk_xxxx", "enabled": true }
]
}
**Every other client spells the same three facts differently — copy the shape from its own documentation, not from here.** VS Code is the clearest example of why: its configuration reference (read 6 September 2026) puts servers in .vscode/mcp.json under a "servers" object — *"an object that maps server names to their configurations"* — not an mcpServers array. Pasted as-is, the block above will not load there. The id, the URL and the token are what travel; the JSON around them belongs to whichever client you are configuring.
A local model, n8n, or your own code — n8n's MCP Client node takes the URL and the same Authorization: Bearer header; a model running on your own machine reaches it through any MCP client, so nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:
curl -X POST https://lendra.example.com/mcp \
-H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet: Lendra implements the protocol, not an integration with a particular vendor.
The tools
| Tool | Writes? | What it does |
|---|---|---|
list_assets | no | The catalogue with each item's status, and how many people are queued for it. Filter by available, category, q. |
asset_detail | no | One asset: current loan, the reservation queue in order, recent history. Accepts asset_id or asset_tag. |
issue_asset | YES | Lends an asset to a borrower. |
return_asset | YES | Checks a loan back in; reports the asset's status (maintenance when it came back damaged) and who the item was then offered to. |
overdue_report | no | Everything past due, most overdue first, with min_days. |
One asset, one loan — and the agent gets the same guard you do
issue_asset calls the product's own Checkout::checkOut() and validates nothing itself. That is the whole point: the availability rule, the inactive-borrower rule, the "a loan cannot start overdue" rule and the reservation queue all live in one function, so the desk, the REST API and an AI agent can never disagree about whether a lend is allowed.
The final flip is an atomic conditional update — `UPDATE assets SET status='checked_out' WHERE id=? AND status='available'` inside a transaction — so **two agents racing to lend the same item cannot both succeed**. The loser gets a refusal it can read:
{"content":[{"type":"text","text":"That asset is already checked out. Check it in first."}],
"isError":true}
Because of that, a tool call can legitimately fail even though a list_assets you fetched a moment earlier said the item was free. That is not a bug to retry blindly around — it means somebody else got there first.
Reservations
If an asset is being held for someone in the queue, issue_asset refuses for anybody else and names who it is held for and until when. The holder themselves can take it, and doing so marks their reservation fulfilled. See "Reservations" in the README for the queue rule.
Refusals
- Arguments must be single values; nothing nested is accepted (no tool allowlists nesting).
- Unknown arguments are refused by name.
- Roles apply exactly as in the browser: a viewer's key cannot issue or return, and the refusal says so in a sentence the model can act on.
Every MCP write lands in the audit trail with via: mcp.
Calendar feed (new in 3.0)
A read-only iCal feed of due-back dates lives at /calendar/due.ics?t=<token>; mint the token in Settings. It is deliberately not behind the session (calendar clients cannot log in), it is revocable by rotating the token, and it carries no email addresses — a subscription URL tends to end up pasted somewhere its owner did not intend.