# Rentara API, MCP & Webhooks (v3.0)

Rentara's REST API exposes the rent ledger the way your accountant actually wants it: every
charge, what was paid against it, and what is still owed — with the arrears list computed the
same way the arrears page computes it, not by a second implementation that can drift.

## Authentication

Create a key in **Settings → API & webhooks**. It is shown once.

```
Authorization: Bearer apk_<40 hex chars>
```

A key acts as the user it belongs to and can do exactly what that user can do in the browser.
`/api/*` is CSRF-exempt by design: the key is the credential and browsers never send it
implicitly.

```bash
curl -H "Authorization: Bearer apk_xxxx" https://rentara.example.com/api/ping
# {"ok":true,"user":"Sam","app":"rentara","version":"3.1.0","role":"admin"}
```

## Endpoints

| Method | Path | Notes |
|---|---|---|
| GET | `/api/ping` | Verify a key |
| GET | `/api/units` | `?status=vacant\|occupied` |
| GET | `/api/leases` | `?status=active\|ended\|upcoming` |
| GET | `/api/leases/{id}/ledger` | Every charge + billed/paid/balance totals |
| POST | `/api/leases/{id}/payments` | Record a payment against one charge, part of one (`amount_cents`), or against the lease, oldest charge first (`amount_cents`, no `charge_id`) |
| GET | `/api/arrears` | Everything overdue right now |
| GET | `/api/maintenance` | `?status=open\|in_progress\|resolved` |
| POST | `/api/maintenance` | Open a ticket |
| POST | `/api/maintenance/{id}/status` | Move a ticket |
| GET | `/api/openapi.json` | OpenAPI 3.0.3 — imports as a custom connector |
| GET | `/healthz` | Unauthenticated `{"ok":true,"app":"rentara","version":"2.0.0"}` |

### The lease ledger

```bash
curl -H "Authorization: Bearer apk_xxxx" https://rentara.example.com/api/leases/7/ledger
```

```json
{
  "lease": {"id": 7, "unit": "1A", "property": "Oakwood", "tenant": "R. Vance",
            "rent_cents": 120000, "frequency": "monthly", "status": "active"},
  "charges": [
    {"id": 41, "type": "rent", "period": "2026-07", "label": "Rent 2026-07",
     "due_date": "2026-07-01", "amount_cents": 120000, "paid": true,
     "paid_date": "2026-07-02", "method": "transfer"},
    {"id": 58, "type": "rent", "period": "2026-08", "label": "Rent 2026-08",
     "due_date": "2026-08-01", "amount_cents": 120000, "paid": false,
     "paid_date": null, "method": null}
  ],
  "totals": {"billed_cents": 240000, "paid_cents": 120000, "balance_cents": 120000}
}
```

Requesting a ledger runs the same catch-up the Rent page runs, so every period that *should*
have been billed by today is present before the totals are computed. You never see a
half-posted ledger just because nobody opened the app this month.

### Recording a payment

```bash
curl -X POST https://rentara.example.com/api/leases/7/payments \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"charge_id": 58, "method": "transfer", "paid_date": "2026-08-03"}'
```

`charge_id` attaches the payment to a specific charge, which is what makes the ledger add up. From
3.2, `amount_cents` below that charge's amount records a **part payment**: the charge keeps the paid
part and a balance row for the same period carries the rest. And `amount_cents` with **no**
`charge_id` pays the lease's oldest unpaid charges first, splitting the last one it reaches; more than
is owed is refused with `409`:

```bash
curl -X POST https://rentara.example.com/api/leases/7/payments \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"amount_cents": 150000, "method": "transfer"}'
``` The posting rules are the product's own (`Billing::recordPayment`), identical to the
Rent page:

- an unrecognized `method` becomes `other` rather than storing junk
- a missing or malformed `paid_date` becomes today
- **re-paying an already-paid charge is refused** with `409`, so a retried webhook or a
  double-submitted form cannot mark the same rent paid twice

```json
{"error":"conflict","detail":"That charge is already marked paid — reverse it first to re-post."}
```

### Arrears

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

Returns every unpaid charge whose due date has passed, with `days_overdue`, plus
`total_cents` for the whole book. This is computed on request from the charge table — Rentara
runs no cron, so there is no stale nightly snapshot to disagree with the screen.

### Maintenance

```bash
curl -X POST https://rentara.example.com/api/maintenance \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"unit_id": 12, "title": "No hot water", "priority": "high"}'
```

Status moves run through the product's transition table: `open ↔ in_progress → resolved`, and
`resolved` reopens to `open`. Resolving stamps `resolved_at`; reopening clears it. An illegal
move is refused with `422`.

## MCP — the endpoint for AI agents (new in 3.0)

Rentara speaks the **Model Context Protocol** at `POST /mcp`, stateless streamable-HTTP: one
JSON-RPC message in, one JSON response out. Authentication is the **same API key** as the REST
surface, and it carries the same role. `GET /mcp` answers **405 with `Allow: POST`** rather than
404, so a probing client can tell "wrong method" from "no MCP support".

    POST /mcp
    Authorization: Bearer rk_...
    Content-Type: application/json

    {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}

| Tool | Reads or writes | What it does |
|---|---|---|
| `list_properties` | READ-ONLY | Properties with unit counts and occupancy. |
| `list_leases` | READ-ONLY | Leases with unit, tenant, rent and status; `active=true` to narrow. |
| `lease_detail` | READ-ONLY | One lease with its full ledger and current balance. |
| `rent_roll` | READ-ONLY | Billed vs collected for a month. |
| `arrears_report` | READ-ONLY | Who is behind and by how much. |
| `create_ticket` | **WRITES** | Raise a maintenance ticket against a unit. |
| `advance_ticket` | **WRITES** | Move a ticket through the product's own transition table. |

**Nothing is re-derived.** `arrears_report` returns the output of `Rent::arrears()` and `rent_roll`
that of `Rent::monthTotals()` — the same functions the screens render. The suite asserts *equality*
between the tool's answer and the engine's rather than trusting the code to stay honest: an agent
that disagreed with the landlord about who owes what would be worse than no agent.

**`advance_ticket` uses the product's transition table, reopen included.** The legal moves are
`open ⇄ in_progress → resolved` and **`resolved → open`**. That last one is deliberate and it is
the rule a re-implementation would quietly drop: a job marked fixed that was not fixed has to go
back. Any other move is refused with the permitted ones named — `resolved → in_progress`, for
instance, is not a legal step. An agent is held to exactly the same table as a person clicking in
the app, because there is only one table.

**Arguments are guarded.** Every argument must be a single scalar; unknown arguments are refused
*by name*, and values outside an enum are refused with the permitted list.

**Roles apply identically.** A viewer's key can call every read tool and is refused on
`create_ticket` and `advance_ticket` with a message naming the role.

### 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://rentara.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 rentara https://rentara.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": "rentara",
  "server_url": "https://rentara.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://rentara.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:
Rentara implements the protocol, not an integration with a particular vendor.

## Webhooks

Add a receiver in **Settings → API & webhooks**. Events:

| Event | Fires when |
|---|---|
| `payment.recorded` | A payment is posted (browser **or** API) |
| `maintenance.created` | A ticket is opened — **including tenant-portal submissions** |
| `maintenance.status_changed` | A ticket moves state |

`maintenance.created` firing on the tenant portal is the one to wire up first: a tenant
reporting no hot water at 22:00 reaches your phone without anyone watching a dashboard.

Each delivery is a JSON POST signed with your per-endpoint secret:

```
X-Rentara-Event: maintenance.created
X-Rentara-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-Rentara-Signature"]):
    abort(401)
```

One attempt per event with a 5-second timeout — a slow receiver must never block someone
recording a payment. The last 200 deliveries per endpoint are logged with their response code.

## Errors

| Code | Meaning |
|---|---|
| `401 unauthorized` | Missing, malformed, revoked or unknown key |
| `404 not_found` | No such lease / ticket |
| `409 conflict` | Charge already paid |
| `422 validation` | Refused by a guard — `detail` says which, in plain language |

## Money

Every amount is **integer cents** (`120000` = 1,200.00) in both directions. No floats, ever.


## Connect it to Zapier, Make or n8n

Rentara 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 Rentara, 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://rentara.yourcompany.com`.

### When something happens in Rentara: 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 Rentara under **Settings → API & webhooks**, and choose the event or events below.
3. Make the event happen once in Rentara, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `payment.recorded` | A payment is posted (browser or API) |
| `maintenance.created` | A ticket is opened — including tenant-portal submissions |
| `maintenance.status_changed` | A ticket moves state |

Every delivery carries `X-Rentara-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 Rentara

- **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/leases/{id}/payments`: Record a payment against one charge, part of one (`amount_cents`), or against the lease, oldest charge first (`amount_cents`, no `charge_id`)
- `POST /api/maintenance`: Open a ticket
- `POST /api/maintenance/{id}/status`: Move a ticket

### Example recipes

- **payment.recorded → a row in Google Sheets:** webhook trigger on `payment.recorded` (A payment is posted (browser or API)), then the Google Sheets "Create row" action with the fields you want.
- **maintenance.created → a message in Slack or Teams:** webhook trigger on `maintenance.created` (A ticket is opened — including tenant-portal submissions), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Rentara:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/maintenance` (Open a ticket).

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