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). 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}/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/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.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 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:

{"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"
}

**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"*. 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:

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.

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.
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
permit.expiringThere 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.

← Back to Permora · Manual · Quickstart · Test run