# Permora API (v2.0)

## The deadlines feed

Permora runs no cron. Nothing wakes up at 3am to decide a permit is expiring — the expiry
state is computed the moment you ask for it, from the dates in your database. That is what
makes `/api/deadlines` trustworthy: it can never be stale, because there is no cache to go
stale.

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

```json
{
  "today": "2026-08-06",
  "thresholds": {"notice": 60, "warning": 30, "critical": 7},
  "overdue_inspections":  [{"inspection_type": "Rough-in", "scheduled_date": "2026-08-01",
                            "job_name": "Kitchen remodel", "days_until": -5, "overdue": true}],
  "upcoming_inspections": [{"inspection_type": "Final", "scheduled_date": "2026-08-09",
                            "job_name": "Deck", "days_until": 3, "upcoming": true}],
  "expiring_permits":     [{"permit_number": "BP-100", "expiry_date": "2026-08-11",
                            "expiry": {"state": "critical", "days": 5, "alert": true,
                                       "phrase": "Expires in 5 days"}}],
  "counts": {"overdue": 1, "upcoming": 1, "expiring": 1}
}
```

Poll it from your scheduling board, a morning Slack digest, or a phone widget. The
`thresholds` block is echoed back so a consumer can explain *why* something is flagged
without hardcoding your windows.

## 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/jobs` | `?status=active\|closed` |
| POST | `/api/jobs` | Create a job |
| GET | `/api/jobs/{id}` | One job with its permits (expiry state attached). From 3.3 each job carries `work_finished_on` and each permit `deposit_cents`, `deposit_paid_on` and `deposit_refunded_on` (blank = not refunded) |
| POST | `/api/jobs/{id}/permits` | Add a permit |
| GET | `/api/permits` | Add `?expiring` for the alert set only |
| POST | `/api/permits/{id}/inspections` | Record an inspection result |
| GET | `/api/deadlines` | The feed above |
| GET | `/api/alerts` | (3.2) Read-only: today's alert items (permits in a window or expired, inspections tomorrow, overdue inspections, jobs with nothing booked, from pass B `permits_to_pull` and `ahj_follow_ups`, and from 3.3 `deposits_to_get_back`), with no addresses. Nothing here sends the email |
| GET | `/api/openapi.json` | OpenAPI 3.0.3 — imports as a custom connector |
| GET | `/healthz` | Unauthenticated `{"ok":true,"app":"permora","version":"2.0.0"}` |

### Expiring permits

```bash
curl -H "Authorization: Bearer apk_xxxx" "https://permora.example.com/api/permits?expiring"
```

Returns exactly what the dashboard's "expiring permits" panel shows: permits that are not
closed, that have an expiry date, and whose computed state is one of `expired`, `critical`,
`warning` or `notice`. The windows are your Settings values (default: notice 60 / warning 30 /
critical 7 days), and the same `Permits::expiryState` call decides it in both places — the API
and the screen cannot disagree.

Every permit carries the full block:

```json
"expiry": {"state": "warning", "days": 19, "alert": true, "phrase": "Expires in 19 days"}
```

`state` is `expired` · `critical` · `warning` · `notice` · `ok` · `none` (no expiry date set).

### Recording an inspection

```bash
curl -X POST https://permora.example.com/api/permits/14/inspections \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"inspection_type": "Rough-in", "result": "fail",
       "scheduled_date": "2026-08-05", "inspector": "J. Diaz",
       "notes": "Missing ground at panel"}'
```

`result` is `pending` · `pass` · `fail` · `partial`; an unrecognized value normalizes to
`pending` rather than erroring, exactly as the form does. Recording a result fires
`inspection.recorded` — and a `fail` *additionally* fires `inspection.failed`, so a receiver
can route the one result that stops work differently from routine records.

### Creating a permit

`POST /api/jobs/{id}/permits` requires **at least a permit type or a permit number** — the same
rule the form enforces, because a permit with neither cannot be identified later:

```json
{"error":"validation","detail":"Enter at least a permit type or a permit number."}
```

## Webhooks

| Event | Fires when |
|---|---|
| `permit.created` | A permit is added (browser, API **or** AI tool) |
| `inspection.recorded` | An inspection is booked with a result, or a pending visit gets its verdict (the job timeline, the edit form, the API or the AI tool) |
| `inspection.failed` | …and specifically when that result is `fail` |

A CSV import records its rows without sending an event per row.

Signed with your per-endpoint secret:

```
X-Permora-Event: inspection.failed
X-Permora-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-Permora-Signature"]):
    abort(401)
```

One attempt per event with a 5-second timeout. The last 200 deliveries per endpoint are logged
with their response code.

**There is no `permit.expiring` webhook, deliberately.** Expiry is a date passing, not an event
anything in Permora observes — firing it would require a background job, and Permora has none.
Poll `/api/deadlines` or `/api/permits?expiring` on your own schedule instead; you get the same
answer, computed fresh, and you control the cadence.

## Errors

| Code | Meaning |
|---|---|
| `401 unauthorized` | Missing, malformed, revoked or unknown key |
| `404 not_found` | No such job / permit |
| `422 validation` | Refused by a guard — `detail` says which, in plain language |

## MCP — the agent surface (new in 3.0)

Permora speaks **MCP** (Model Context Protocol) at `POST /mcp`, so an AI assistant can read your
permits and keep the inspection record straight without anyone writing an integration. Same
server, same data, same rules — an agent is just another client.

**Authentication is the API key you already have:**

    Authorization: Bearer <your api key>

The transport is stateless streamable-HTTP: one JSON-RPC request per POST, no session to keep
alive. `GET /mcp` answers **405** with `Allow: POST` so a misconfigured client is told what to do
rather than left guessing.

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

### Tools

| Tool | Writes? | What it does |
|---|---|---|
| `list_permits` | no | Permits with expiry status. Filter by job, status, or expiring-only |
| `permit_detail` | no | One permit WITH its full inspection history and re-inspection links |
| `create_permit` | **YES** | Records a permit you have pulled on a job |
| `schedule_inspection` | **YES** | Books an inspection against a permit; it starts Pending |
| `record_inspection_result` | **YES** | Records pass / fail / partial, and can spawn the linked follow-up |
| `upcoming_inspections` | no | What is still to happen, and what is already overdue |
| `expiry_report` | no | Permits by urgency, using **your** notice / warning / critical windows |

Every description in `tools/list` states plainly whether the tool writes, because that is what an
agent needs in order to decide whether it is allowed to call it.

### The inspection chain is one rule, not two

`record_inspection_result` calls **the same writer the screen calls**, and the re-inspection is
built by `Permits::newReinspection()` — the function behind the Re-inspect button. So:

- **An inspection is decided once.** A visit that already carries a verdict is not re-judged:

      {"content":[{"type":"text","text":"Inspection 12 is already recorded as Fail. An inspection is decided once — if it was re-visited, schedule a re-inspection from it instead, which links the new visit to this one and keeps both on the record."}],
       "isError":true}

  This holds on the edit screen too. If it only held for agents, the claim on this page would be
  false the first time somebody used the browser.

- **Only a failed or partial inspection can be re-inspected.** Asking for a follow-up to a pass is
  refused, and the refusal names the actual result.

- **A re-inspection links back.** The new visit carries `reinspection_of`, so the permit file shows
  a chain rather than two unrelated rows with the same date.

`expiry_report` uses the install's own notice / warning / critical day windows and **reports those
windows in its answer**, so an agent can explain why something is critical instead of inventing a
threshold of its own.

Argument hygiene mirrors the REST API: every argument must be a single value, an unknown argument
is refused **by name**, and an out-of-enum value lists the valid options. Roles apply exactly as in
the browser: a viewer's key is refused every writing tool.

### Quick check

    curl -s -X POST http://your-install/mcp \
      -H "Authorization: Bearer <key>" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

## Calendar feeds (new in 3.0)

    GET /expiries.ics?t=<token>        permit expiry dates
    GET /inspections.ics?t=<token>     scheduled inspections still to happen

Two feeds, two tokens, minted separately in Settings. Only the hashes are stored, exactly like an
API key. A GC who needs your inspection dates should not thereby receive your commercial expiry
calendar, and revoking one must not revoke the other.

## Job permit file (new in 3.0)

    GET /jobs/{id}/report.pdf

One job, every permit, and the full inspection history under each — the artifact for the GC or the
building department. Requires a signed-in session or an API key.


## Connect it to Zapier, Make or n8n

Permora 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 Permora, 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://permora.yourcompany.com`.

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

| Event | Fires when |
|---|---|
| `permit.created` | A permit is added (browser, API or AI tool) |
| `inspection.recorded` | An inspection is booked with a result, or a pending visit gets its verdict (the job timeline, the edit form, the API or the AI tool) |
| `inspection.failed` | …and specifically when that result is `fail` |
| `permit.expiring` | There is no webhook, deliberately. Expiry is a date passing, not an event |

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

- **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/jobs`: Create a job
- `POST /api/jobs/{id}/permits`: Add a permit
- `POST /api/permits/{id}/inspections`: Record an inspection result

### Example recipes

- **permit.created → a row in Google Sheets:** webhook trigger on `permit.created` (A permit is added (browser, API or AI tool)), then the Google Sheets "Create row" action with the fields you want.
- **inspection.recorded → a message in Slack or Teams:** webhook trigger on `inspection.recorded` (An inspection is booked with a result, or a pending visit gets its verdict (the job timeline, the edit form, the API or the AI tool)), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Permora:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/jobs` (Create a job).

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