Ownware
Home›Helpora›API
Helpora · API

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

  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 recognisable 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.

Endpoints

Method & pathWhat it does
GET /api/pingVerify 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}/statusMove a request. Settling runs the same refusal the screen runs
GET /api/slaAggregate counters — by SLA state, by status, and first-reply met / missed / never answered
GET /api/openapi.jsonOpenAPI 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, 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)

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.

ToolWrites?What it does
list_casesnoThe register, each row carrying target, stamp and verdict. Filter by status, SLA status, overdue, or unanswered
case_detailnoOne request with its text, its timeline (including the clock events) and its thread. Says whether it may be settled, and why not
statsnoCounts by status and SLA, plus first-reply met / missed / never answered counted separately
post_handler_messageYESReplies 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_requestYESResolves 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 addresshttps://helpdesk.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 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_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:
{"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. 0 days means never, and that is the default. The audit entry is {"count":N} — naming the requests would defeat the purge.

Roles

RoleCan
viewerSee 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.

← Back to Helpora · Manual · Quickstart · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →