Jobora API, as shipped in the download
Jobora API
Jobs, the schedule, quotes and invoices, over HTTP and over MCP. Everything below runs against your installation — there is no Jobora cloud, no relay and no telemetry.
Authentication
Mint a key in Settings → API & webhooks. It is shown once and stored as a SHA-256 hash.
Authorization: Bearer apk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Key scope — read or full
Every key carries a scope, chosen when you mint it.
| Scope | What it can do |
|---|---|
read | GET, HEAD and OPTIONS. Any other method is refused with 403 at the door, before a route runs. |
full | Everything the key's user role allows. |
The refusal is enforced once, in Api::requireKey(), rather than in each route — the route that forgets is exactly the one that would leak. On the MCP endpoint the same scope hides write tools from tools/list and refuses them by name on tools/call.
A key never exceeds its user's role: a key minted by a member cannot do what that member could not do in the browser, and a viewer's key is refused every write with 403 — on /api/* as well as on the agent endpoint (from 1.0.4; before, the REST writes checked only the key's scope).
curl -H "Authorization: Bearer apk_xxxx" https://jobora.example.com/api/ping
# {"ok":true,"user":"Sam","app":"jobora","version":"1.1.0"}
Endpoints
The machine-readable spec is at /api/openapi.json.
| Method | Path | What it does | ||||
|---|---|---|---|---|---|---|
| GET | /api/ping | Verify a key | ||||
| GET | /api/customers | Customers on file | ||||
| GET | /api/jobs | Jobs, newest 200. `?status=scheduled\ | in_progress\ | on_hold\ | completed\ | cancelled. Each job carries site_address` (1.1), which is how Permora brings a job in for its permits |
| POST | /api/jobs | Raise a job (customer_id, title required) | ||||
| GET | /api/jobs/{id} | One job with its visits, costs, margin, sign-off state and invoice | ||||
| POST | /api/jobs/{id}/status | Move it along. Refused 409 when the move is not legal | ||||
| GET | /api/visits | Booked visits in a window (?from=&to=) | ||||
| POST | /api/visits | Book a visit. Refused 409 when that technician is already out | ||||
| GET | /api/quotes | Quotes or invoices (`?kind=quote\ | invoice`) | |||
| GET | /api/quotes/{id} | One document with lines and totals | ||||
| POST | /api/quotes/{id}/accept | Accept a quote — this creates the job (and, with a deposit percent set, raises the deposit invoice, 1.1) | ||||
| GET | /api/chaser | Read-only (1.1): where each open invoice, unanswered quote and tomorrow's visits stand in the automatic follow-up schedule — today's state, the reason if held, and when the next step goes. No addresses, no message bodies; nothing here can send |
Money and time, so a caller is never surprised
- Money is integer cents, always, and Jobora computes it. You send quantities and unit prices; subtotal, discount, tax and total come back computed. A caller cannot post a total.
margin_centsandmargin_pctarenullwhen nothing was quoted. That means unknown, not broke even. A job with £600 of cost and no quote is not a £600 loss — nobody priced it here.- Schedule times are local wall-clock minutes (
"09:00"), not timestamps. A field day is planned in wall-clock, and storing an instant would move every future booking by an hour at a daylight-saving change.
Booking a visit is refused the same way everywhere
curl -X POST https://jobora.example.com/api/visits \
-H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
-d '{"job_id":12,"tech":"Dan Whitlow","visit_date":"2026-08-24","start":"09:00","end":"11:00"}'
# 409 {"error":"conflict","detail":"Dan Whitlow is already booked 09:00–11:00 that day."}
That sentence comes from Schedule::conflict() — the same function the drag-and-drop board, the booking form and the MCP tool call. There is one rule, and four doors that ask it.
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://jobora.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:
claude mcp add --transport http jobora https://jobora.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": "jobora",
"server_url": "https://jobora.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://jobora.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: Jobora implements the protocol, not an integration with a particular vendor.
Tools
| Tool | Writes? | What it does |
|---|---|---|
list_jobs | no | Jobs with customer, status, quoted, cost and margin |
job_detail | no | One job: visits, costs, whether it is signed off, its invoice |
schedule_day | no | Who is where on a date, in start-time order |
margin_report | no | Quoted against actual across completed jobs; unquoted jobs counted separately |
create_job | yes | Raise a job for a customer |
book_visit | yes | Put a job on the schedule; refused on a clash |
advance_job | yes | Move a job along its life cycle; a completed job is never reopened |
accept_quote | yes | Accept a quote, which converts it into a job — once |
There is deliberately no sign-off tool, no delete tool and no record-payment tool. A customer signature is a person at a door agreeing the work is done; a machine cannot hold the pen, and a forged one is worse than a missing one. Deleting a job would take its sign-off and photo evidence with it. An agent can plan your week and tell you which jobs lost money — it cannot sign for work or erase the record of it.
Webhooks
Add them in Settings → API & webhooks. Each delivery carries X-Jobora-Signature: sha256=<hmac> over the raw body, using the secret shown when you created it.
| Event | Fires when |
|---|---|
job.created | a job is raised, from any door |
job.scheduled, job.in_progress, job.on_hold, job.completed, job.cancelled | a job moves to that status, from any door |
job.signed_off | a customer signs |
quote.accepted | a quote converts into a job, from any door |
invoice.paid | (1.1) a card payment through your own Stripe account was recorded on an invoice; carries the invoice and payment (amount_cents, currency, via) |
These are exactly the events the settings page offers (from 1.0.4; before, it offered another product's events and none of these could be ticked).
Stripe's webhook into Jobora (1.1)
POST /hooks/stripe is where your own Stripe account tells Jobora a card payment went through. It is not part of the key-authenticated API: the Stripe-Signature header, checked against the signing secret you saved in Settings, is the authorization. Unsigned, wrongly signed or stale (over five minutes) requests get 400 and change nothing. Only checkout.session.completed and checkout.session.async_payment_succeeded for a paid checkout, in your configured currency, with a matching invoice number, record a payment, and each checkout session is recorded once.
Errors
Standard HTTP, with a body: {"error":"conflict","detail":"…"}.
| Code | Means |
|---|---|
| 401 | missing or unknown key |
| 403 | the key is read-only and this is a write, or the role is not allowed |
| 404 | no such record |
| 409 | the product refused: a clash, an illegal status move, an already-converted quote |
| 422 | the input is wrong (bad date, missing field) |
Limits, honestly
- No pagination beyond the caps. Lists return the newest 200 (500 for MCP
list_jobs). A field business does not have a million jobs, and a cursor nobody needs is a cursor nobody tests. - No rate limiting on the API. It is your server; the login has a rate limit, the API does not.
- No webhook retries. A delivery is attempted once and the result is recorded. If your endpoint was down, re-read the record — do not expect Jobora to replay it.
Connect it to Zapier, Make or n8n
Jobora 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 Jobora, 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://jobora.yourcompany.com.
When something happens in Jobora: send it to another tool
- 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.
- Copy that URL into Jobora under Settings → API & webhooks, and choose the event or events below.
- Make the event happen once in Jobora, so the other tool receives a sample and you can map its fields.
| Event | Fires when |
|---|---|
job.created | A job is raised, from any door |
job.signed_off | A customer signs |
quote.accepted | A quote converts into a job, from any door |
invoice.paid | A card payment through your own Stripe account was recorded on an invoice; carries the invoice and payment (amount_cents, currency, via) |
job.scheduled | A job moves to scheduled, from any door |
job.in_progress | A job moves to in progress, from any door |
job.on_hold | A job moves to on hold, from any door |
job.completed | A job moves to completed, from any door |
job.cancelled | A job moves to canceled, from any door |
Every delivery carries X-Jobora-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. Jobora makes one delivery attempt and records the result either way; if the other tool was down, re-read the record through the API.
When something happens elsewhere: act in Jobora
- 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: Raise a job (customer_id,titlerequired)POST /api/jobs/{id}/status: Move it along. Refused 409 when the move is not legalPOST /api/visits: Book a visit. Refused 409 when that technician is already outPOST /api/quotes/{id}/accept: Accept a quote — this creates the job (and, with a deposit percent set, raises the deposit invoice, 1.1)
Example recipes
- job.created → a row in Google Sheets: webhook trigger on
job.created(A job is raised, from any door), then the Google Sheets "Create row" action with the fields you want. - job.signed_off → a message in Slack or Teams: webhook trigger on
job.signed_off(A customer signs), then the Slack or Microsoft Teams "Send message" action. - A new form entry or row elsewhere → Jobora: the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling
POST /api/jobs(Raise a job (customer_id,titlerequired)).
These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Jobora's own automated tests check its API.