Home›Jobora›API
Jobora · API

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.

ScopeWhat it can do
readGET, HEAD and OPTIONS. Any other method is refused with 403 at the door, before a route runs.
fullEverything 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.

MethodPathWhat it does
GET/api/pingVerify a key
GET/api/customersCustomers on file
GET/api/jobsJobs, 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/jobsRaise 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}/statusMove it along. Refused 409 when the move is not legal
GET/api/visitsBooked visits in a window (?from=&to=)
POST/api/visitsBook a visit. Refused 409 when that technician is already out
GET/api/quotesQuotes or invoices (`?kind=quote\invoice`)
GET/api/quotes/{id}One document with lines and totals
POST/api/quotes/{id}/acceptAccept a quote — this creates the job (and, with a deposit percent set, raises the deposit invoice, 1.1)
GET/api/chaserRead-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_cents and margin_pct are null when 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 addresshttps://jobora.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 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

ToolWrites?What it does
list_jobsnoJobs with customer, status, quoted, cost and margin
job_detailnoOne job: visits, costs, whether it is signed off, its invoice
schedule_daynoWho is where on a date, in start-time order
margin_reportnoQuoted against actual across completed jobs; unquoted jobs counted separately
create_jobyesRaise a job for a customer
book_visityesPut a job on the schedule; refused on a clash
advance_jobyesMove a job along its life cycle; a completed job is never reopened
accept_quoteyesAccept 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.

EventFires when
job.createda job is raised, from any door
job.scheduled, job.in_progress, job.on_hold, job.completed, job.cancelleda job moves to that status, from any door
job.signed_offa customer signs
quote.accepteda 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":"…"}.

CodeMeans
401missing or unknown key
403the key is read-only and this is a write, or the role is not allowed
404no such record
409the product refused: a clash, an illegal status move, an already-converted quote
422the 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

  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 Jobora under Settings → API & webhooks, and choose the event or events below.
  3. Make the event happen once in Jobora, so the other tool receives a sample and you can map its fields.
EventFires when
job.createdA job is raised, from any door
job.signed_offA customer signs
quote.acceptedA quote converts into a job, from any door
invoice.paidA card payment through your own Stripe account was recorded on an invoice; carries the invoice and payment (amount_cents, currency, via)
job.scheduledA job moves to scheduled, from any door
job.in_progressA job moves to in progress, from any door
job.on_holdA job moves to on hold, from any door
job.completedA job moves to completed, from any door
job.cancelledA 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, title required)
  • POST /api/jobs/{id}/status: Move it along. Refused 409 when the move is not legal
  • POST /api/visits: Book a visit. Refused 409 when that technician is already out
  • POST /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, title required)).

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.

← Back to Jobora · Manual · Quickstart · Test run