# Helpora REST API & Webhooks (v3.0)

**Desk side only.** Helpora's API exists so a support team can run the desk from their own tools.
It does not reach the requester's side of a thread, and it is held to the same clock the screens
are.

## The rules this API is built around

1. **The clock is not negotiable at the API door.** Everything the screens refuse, the API refuses,
   by calling the same functions: settling a request nobody answered, pausing one nobody has been
   written to. An integration is exactly the tool somebody would
   otherwise reach for to route around a rule.
2. **`first_response_at` is written once, by the first outbound message, and by nothing else.**
   There is exactly one UPDATE that touches it in the entire product, guarded on
   `WHERE first_response_at IS NULL`. No endpoint can reset it, and none should want to.
3. **There is no requester-side API.** API keys belong to the desk. Requesters use their reference
   and a passphrase they chose, in a browser. An agent who could post as a requester could forge
   the reply that ends a pause — that is, could restart the desk's own clock.
4. **`passphrase_hash` never leaves the server.** `Api::caseOut()` is an allow-list, not a row
   splat. A test plants a recognizable hash and asserts it appears nowhere in the JSON.
5. **Webhooks carry metadata and clock facts, never content.** No request details, no message
   bodies. A receiver is an operational trigger, not a disclosure channel.
6. **A key does what its holder's role allows, and no more.** Every write checks the key holder's
   role, exactly as the screen does: a viewer's key cannot change a request (`403 forbidden`).

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /api/ping` | Verify a key |
| `GET  /api/requests` | `?status=` · `?sla=on_track\|response_breached\|resolution_breached` — **list omits request details** |
| `GET  /api/requests/{code}` | One request **with** its details and full thread |
| `POST /api/requests/{code}/status` | Move a request. Settling runs the same refusal the screen runs |
| `GET  /api/sla` | Aggregate counters — by SLA state, by status, and first-reply met / missed / **never answered** |
| `GET  /api/openapi.json` | OpenAPI 3.0 |

The older `/api/cases…` paths answer exactly as the `/api/requests…` ones do, so an integration
written against either keeps working.

### Every row carries its own working

A request row is deliberately shaped so a consumer never has to re-derive the one number the
product is about:

```json
{
  "code": "QUIETQ7MRK",
  "queue": "Access or account",
  "priority": "urgent",
  "status": "new",
  "sla_status": "response_breached",
  "received_at": "2026-08-21 14:10:00",
  "response_target_at": "2026-08-21 15:10:00",
  "first_response_at": null,
  "response_met": null,
  "resolution_target_at": "2026-08-24",
  "paused_minutes": 0,
  "clock_paused": false
}
```

`response_met` has **three** states and they are not interchangeable: `true` (in time), `false`
(late — and it stays false), `null` (nobody has written to this person at all). A report that
folded `null` into "on track" would describe a desk with forty unanswered requests as performing
perfectly.

### Settling is refused when nobody answered

    curl -X POST https://your-install/api/requests/QUIETQ7MRK/status \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"status": "closed", "resolution": "Tidying up the queue."}'

    409 → {"error":"conflict","detail":"This request has never been answered. Write to the person
           who raised it before resolving or closing it — a desk that can settle in silence will,
           and its response times will look excellent."}

The same call succeeds the moment somebody has actually replied. Nothing else about the request
needs to change.

## Two-factor authentication — turn it on

**Turn 2FA on for every agent.** An agent account can read every request and every reply on the
desk. Settings → Security enrolls an authenticator app with a
QR rendered locally (no external service) and ten one-time recovery codes.

## Webhooks

Add receiver URLs in **Settings → API & Webhooks**. Events:

- `request.first_response` — fired **only** for the message that actually was the first response,
  carrying the target, the stamp, and `met: true|false`. It fires once per request, for the same
  reason the column is written once.
- `request.settled` — the outcome, whether the first reply met its target, and how many minutes
  the clock was legitimately stopped
- `request.status_changed` — a move, with the previous status
- `request.message` — the requester wrote back (which is also what ends a pause)
- `request.rated` — (1.1) the requester said whether it helped, once the request was settled: `helped: true|false`

A request (`GET /api/requests/{code}`) carries `rating` (`true` it helped, `false` it did not, `null` not rated) and `rating_comment` (1.1).

Each delivery is an HTTP POST with a JSON body and:

    X-Helpora-Event: request.first_response
    X-Helpora-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with the endpoint's secret>

Verify the signature before trusting a payload. One attempt, 5-second timeout — deliberately
simple; the **Recent deliveries** log shows every attempt and response code. Design receivers to be
idempotent.

**Chat channels (1.2).** Give an endpoint the format Slack, Microsoft Teams or Google Chat and it
receives one readable line instead of the JSON above — `{"text": "New request K7Q2M9XR4T in IT
problem, urgent. https://your-desk/requests/42"}` — and Discord receives `{"content": …}`. The line
carries the request's reference, what happened and a link for the signed-in desk; never the subject,
a message or who asked.

## Import — N/A

There is no categories table (categories are a settings list), and there is nothing else that could
sensibly be bulk-loaded: requests arrive from people, one at a time, by design.

## Backup

`/backup.json` exports every table. `passphrase_hash` **is** included — it must survive a restore or
every open requester loses access to their own thread. Only the SMTP credential is redacted.

---

# Own It 3.0 — the agent endpoint and the rest

## MCP — Helpora for an AI assistant

Helpora speaks **MCP** (Model Context Protocol) on one route, so an assistant can triage the
register, open a request and draft a reply — without anyone writing an integration. And it is held
to the same clock the screens are: an agent told to "tidy up the queue" comes back with a refusal,
not with forty quietly-closed requests.

    POST /mcp
    Authorization: Bearer apk_...          ← the SAME revocable key the REST API uses
    Content-Type: application/json

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 at a 404.

| Tool | Writes? | What it does |
|---|---|---|
| `list_cases` | no | The register, each row carrying target, stamp and verdict. Filter by status, SLA status, overdue, or `unanswered` |
| `case_detail` | no | One request with its text, its timeline (including the clock events) and its thread. Says whether it may be settled, and why not |
| `stats` | no | Counts by status and SLA, plus first-reply met / missed / **never answered** counted separately |
| `post_handler_message` | **YES** | Replies to the requester, always as the desk. If it is the first reply it stamps the clock — once — and answers with whether that beat the target |
| `settle_request` | **YES** | Resolves or closes, recording what was done. **Refused** when nobody has ever written to the requester |

A **read-only key sees only the first three.** They are absent from `tools/list`, so the model never
proposes a write in the first place; ask for one by name anyway and the server answers with a
sentence saying the key is read-only.

### 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://helpdesk.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 helpora https://helpdesk.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": "helpora",
  "server_url": "https://helpdesk.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://helpdesk.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:
Helpora implements the protocol, not an integration with a particular vendor.

### There is no requester-side tool, and there never will be

**No tool files a request. No tool reads or writes as the requester.** Stated as a refusal rather
than an omission, because in this product it is load-bearing:

- **Filing:** a request's follow-up rests on the person choosing their own passphrase — it is the
  only thing that lets them come back. An agent filing on someone's behalf creates a request nobody
  can follow up on.
- **Writing as them:** the requester's own message is what ends a pause. An agent that could forge
  one could restart the desk's own resolution clock at will. That is not a hypothetical abuse — it
  is the single most attractive thing an agent optimizing a metric could do.

`post_handler_message` posts as the **desk**, always. `sender` is not a parameter anywhere in the
3.0 layer, and the suite asserts the string `'requester'` appears in no code path in it, and that
the only place a `'requester'` message is written is the requester's own door.

### The clock holds across every tool

- `post_handler_message` returns `was_first_response`, `first_response_at` and `response_met`, so an
  agent can see immediately whether its reply landed in time — and cannot claim it did if it did not.
- A second call returns `was_first_response: false` and the **same** `first_response_at`.
- `settle_request` runs `Request::refusalToSettle()`, the same function the screen and the REST door
  run:

```json
{"content":[{"type":"text","text":"This request has never been answered. Write to the person who raised it before resolving or closing it — a desk that can settle in silence will, and its response times will look excellent."}],
 "isError":true}
```

- `stats` reports `never_answered` separately from breaches, and `met_pct` **with its denominator**,
  because a percentage without one is not a measurement.

Role gates are the same ones the screen applies: a key whose owner is a `viewer` can list and count
but is refused on `case_detail` and on any write.

## Deadline calendar (iCal)

    GET /calendar.ics?t=<token>

Mint the token in **Backup & restore**; the URL is shown once. The feed carries the desk's two
deadlines for every open request, as all-day events: the **first-response target**, only while
nobody has answered yet, and the **resolution date**, which already includes any time the clock was
stopped while waiting on the requester.

It carries the request **code**, the **queue** and which deadline it is. It deliberately does **not**
carry the subject line or the request text. A calendar subscription syncs to phones and third-party
calendar services, which means it leaves this server's control; a subject line is the field most
likely to describe an identifiable situation, and it has no business there.

A wrong or missing token is `403`.

## Request file (PDF)

    GET /requests/{id}/file.pdf

The request as submitted, **both clocks with their verdicts side by side**, the timeline and the
full thread. A missed target prints as `Response met: NO` — a record that quietly omits the misses
is the reason these reports are not believed. The passphrase hash never appears in it, and the suite
asserts that against the live value.

## Retention purge

    GET /purge/run?t=<token>       ← same token as the scheduled backup

Destroys the content of requests closed more than `purge_after_days` ago: request text, message
bodies and the resolution become `[purged]`. The code, queue, status and service-level dates survive,
because they are the evidence that the request was handled on time. The purge does not reach the
requester's address, the subject line, the "did it help?" comment, the request-log notes or attached
photos (4.1.1); **Data requests** erases those for one person. `0` days means never, and that
is the default. The audit entry is `{"count":N}` — naming the requests would defeat the purge.

## Roles

| Role | Can |
|---|---|
| `viewer` | See the register and the audit trail — both clocks, not the threads |
| `member` | …plus open requests, reply, pause, settle, save views |
| `admin` | …plus settings, branding, restore, retention, the team |

An API key can do exactly what its owner can do. `viewer` is a genuine oversight seat: it lets
somebody verify the response-time figures without being handed everybody's correspondence to do it.
The last active admin cannot be demoted or deactivated.

## Erasure

**Data requests** (4.1.1, admin only) exports or erases everything held about one requester, found by the
address they gave. Erasure blanks, on every request of theirs, the address, the subject line, the details,
the resolution, the "did it help?" comment, every message body and the request-log notes, and deletes the
photos they attached. It is refused while any of their requests is still open. It is a page, not an API
route: an API key cannot erase anybody.

The retention purge above is the other half: destruction by time. It destroys the request text, the
message bodies and the resolution for every settled request, and it deliberately **keeps** the reference,
the queue, the status and both clocks with their stamps. That skeleton is the evidence of how the desk
performed; destroying it would destroy the record along with the data, which is the opposite of what a
retention policy is for. The erase keeps the same skeleton. A request raised without an address cannot be
found by person, so the erase cannot reach it.

## Attachments

Off by default, and JPEG/PNG only when you turn them on. Every accepted image is **rebuilt without
its metadata** before storage — the camera model, the GPS coordinates and any author name go — and
what remains is encrypted at rest with this install's own secret, decrypted only while being served.

Documents (PDF, Word) are refused on purpose: their author history cannot be reliably stripped, and
a half-working stripper is worse than no upload button because it converts "I should check this" into
false confidence.

Nothing about an attachment reaches an email, a webhook or the calendar feed. The only upload
surfaces in the product are the requester's image, the admin's branding logo and the admin's restore
file; the suite asserts there is never a fourth.


## Connect it to Zapier, Make or n8n

Helpora 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 Helpora, 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://helpora.yourcompany.com`.

### When something happens in Helpora: 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 Helpora under **Settings → API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Helpora, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `request.first_response` | Fired only for the message that actually was the first response, carrying the target, the stamp, and . It fires once per request, for… |
| `request.settled` | The outcome, whether the first reply met its target, and how many minutes the clock was legitimately stopped |
| `request.status_changed` | A move, with the previous status |
| `request.message` | The requester wrote back (which is also what ends a pause) |
| `request.rated` | The requester said whether it helped, once the request was settled |

Every delivery carries `X-Helpora-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 Helpora

- **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/requests/{code}/status`: Move a request. Settling runs the same refusal the screen runs

### Example recipes

- **request.first_response → a row in Google Sheets:** webhook trigger on `request.first_response` (Fired only for the message that actually was the first response, carrying the target, the stamp, and . It fires once per request, for…), then the Google Sheets "Create row" action with the fields you want.
- **request.settled → a message in Slack or Teams:** webhook trigger on `request.settled` (The outcome, whether the first reply met its target, and how many minutes the clock was legitimately stopped), then the Slack or Microsoft Teams "Send message" action.

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Helpora's own automated tests check its API.
