Ownware
Home›Permora›API
Permora · API

Permora API, as shipped in the download

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.

curl -H "Authorization: Bearer apk_xxxx" https://permora.example.com/api/deadlines
{
  "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

MethodPathNotes
GET/api/pingVerify a key
GET/api/jobs`?status=active\closed`
POST/api/jobsCreate a job
GET/api/jobs/{id}One job with its permits (expiry state attached)
POST/api/jobs/{id}/permitsAdd a permit
GET/api/permitsAdd ?expiring for the alert set only
POST/api/permits/{id}/inspectionsRecord an inspection result
GET/api/deadlinesThe feed above
GET/api/openapi.jsonOpenAPI 3.0.3 — imports as a custom connector
GET/healthzUnauthenticated {"ok":true,"app":"permora","version":"2.0.0"}

Expiring permits

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:

"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

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 unrecognised value normalises 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:

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

Webhooks

EventFires when
permit.createdA permit is added (browser, API or AI tool)
inspection.recordedAn 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)>
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

CodeMeaning
401 unauthorizedMissing, malformed, revoked or unknown key
404 not_foundNo such job / permit
422 validationRefused 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 addresshttps://permora.example.com/mcp
The keyheader Authorization: Bearer apk_xxxx
The transportMCP 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 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):

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

Own Your AI reads a list of servers in this shape:

{
  "mcpServers": [
    { "id": "permora", "name": "Permora", "url": "https://permora.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://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

ToolWrites?What it does
list_permitsnoPermits with expiry status. Filter by job, status, or expiring-only
permit_detailnoOne permit WITH its full inspection history and re-inspection links
create_permitYESRecords a permit you have pulled on a job
schedule_inspectionYESBooks an inspection against a permit; it starts Pending
record_inspection_resultYESRecords pass / fail / partial, and can spawn the linked follow-up
upcoming_inspectionsnoWhat is still to happen, and what is already overdue
expiry_reportnoPermits 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.

← Back to Permora · Manual · Quickstart · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →