# 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.

```bash
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

```bash
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

```bash
curl -H "Authorization: Bearer apk_xxxx" https://lendra.example.com/api/overdue
```

```json
{
  "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 gray out what it cannot issue without guessing at status strings.

Since 3.2.0 an asset also carries `"requestable": true|false` (borrowers can request it from their
own page) and `"fields"`: your own asset fields by name, only those with a value, for example
`"fields": {"Serial number": "5CG3412XQ9", "Warranty until": "2028-03-11"}`. The MCP asset tools
return the same `fields`.

## 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)>
```

```python
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:

```sh
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):

```json
{
  "type": "mcp",
  "server_label": "lendra",
  "server_url": "https://lendra.example.com/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}
```

**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](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(read 6 September 2026) puts servers in `.vscode/mcp.json` under a **`"servers"` object** — *"an
object that maps server names to their configurations"*. Pasted as-is,
a block written for another client 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:

```sh
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 catalog 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:

```json
{"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.


## Connect it to Zapier, Make or n8n

Lendra talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

### Before you start

- In Lendra, open **Settings → API & webhooks**. Mint an API key (choose **Read only** if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example `https://lendra.yourcompany.com`.

### When something happens in Lendra: send it to another tool

1. In the other tool, create the trigger that gives you a URL to receive webhooks:
   - **Zapier:** the "Webhooks by Zapier" app, trigger **Catch Hook** (Webhooks by Zapier needs a paid Zapier plan).
   - **Make:** module **Webhooks → Custom webhook**.
   - **n8n:** the **Webhook** node.
2. Copy that URL into Lendra under **Settings → API & webhooks**, and choose the event or events below.
3. Make the event happen once in Lendra, so the other tool receives a sample and you can map its fields.

| 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) |
| `item.overdue` | There is no webhook, deliberately. Going overdue is a due date passing, not |

Every delivery carries `X-Lendra-Signature`, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

### When something happens elsewhere: act in Lendra

- **Zapier:** "Webhooks by Zapier", action **POST** (or **Custom Request**).
- **Make:** module **HTTP → Make a request**.
- **n8n:** the **HTTP Request** node.

Send the request to your install's address plus the path from the Endpoints table above, with the header `Authorization: Bearer <your API key>` and a JSON body. A read-only key cannot write.

The endpoints that change something:

- `POST /api/checkouts`: Check an item out
- `POST /api/checkouts/{id}/return`: Check it back in

### Example recipes

- **item.checked_out → a row in Google Sheets:** webhook trigger on `item.checked_out` (An item goes out — at the counter, over the API or through an agent), then the Google Sheets "Create row" action with the fields you want.
- **item.returned → a message in Slack or Teams:** webhook trigger on `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)), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Lendra:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/checkouts` (Check an item out).

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Lendra's own automated tests check its API.
