Helpora API, as shipped in the download
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
- 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.
first_response_atis written once, by the first outbound message, and by nothing else. There is exactly one UPDATE that touches it in the entire product, guarded onWHERE first_response_at IS NULL. No endpoint can reset it, and none should want to.- 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.
passphrase_hashnever leaves the server.Api::caseOut()is an allow-list, not a row splat. A test plants a recognisable hash and asserts it appears nowhere in the JSON.- Webhooks carry metadata and clock facts, never content. No request details, no message bodies. A receiver is an operational trigger, not a disclosure channel.
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:
{
"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 enrols 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, andmet: 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 stoppedrequest.status_changed— a move, with the previous statusrequest.message— the requester wrote back (which is also what ends a pause)
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.
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:
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):
{
"type": "mcp",
"server_label": "helpora",
"server_url": "https://helpdesk.example.com/mcp",
"authorization": "apk_xxxx",
"require_approval": "never"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "helpora", "name": "Helpora", "url": "https://helpdesk.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://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 optimising 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_messagereturnswas_first_response,first_response_atandresponse_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: falseand the samefirst_response_at. settle_requestrunsRequest::refusalToSettle(), the same function the screen and the REST door run:
{"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}
statsreportsnever_answeredseparately from breaches, andmet_pctwith 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. 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
Helpora ships no GDPR subject-export/erasure kit, and the honest reason is that this product holds less about a person than the kit assumes: an email address that is optional, and whatever they chose to write. There is no name column, no IP column, and no account.
What Helpora offers instead is the retention purge above — destruction by time. It destroys the request text, the message bodies and the resolution, 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.
If a requester asks to be forgotten, the operator deletes the row. There is no automated path for it, because "find every request this person raised" needs an identity the product does not keep.
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.