# 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).

```sh
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_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

```sh
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:

```sh
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):

```json
{
  "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](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(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:

```sh
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

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.

| 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`, `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.
