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.0.5"}
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` |
| 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 |
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"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "jobora", "name": "Jobora", "url": "https://jobora.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://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 |
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).
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.