API Documentation
REST API v1 โ everything below is implemented and covered by the test suite.
This covers everything your app needs to talk to HelpZeno directly. If you'd rather see it end to end first, the how it works section on the homepage has a two-minute walkthrough.
1. Base URL & versioning
All endpoints below are namespaced under /api/v1 on your HelpZeno domain, e.g.
https://your-domain.example/api/v1/tickets. There's a single stable version today; a breaking
change would ship as /api/v2 rather than changing v1 under you.
2. Authentication
Every request needs two headers โ there is no OAuth flow and no separate login step:
| Header | What it identifies |
|---|---|
X-Api-Key | Which Project (tenant) this request belongs to. Find/regenerate it on the project portal. |
X-User-Id | Which end user is making the request โ any stable identifier from your own system (a UUID, your internal user ID, etc). Up to 191 characters. |
There's no separate "create a user" call: the first request with a new X-User-Id silently
provisions that user within the Project. Missing or invalid X-Api-Key returns
401; missing or blank X-User-Id also returns 401.
This key is not a Stripe key. X-Api-Key authenticates your app against
HelpZeno's own API. Stripe's keys (in your .env as STRIPE_SECRET /
STRIPE_WEBHOOK_SECRET) are only ever used server-side, between HelpZeno and Stripe, for
billing โ your app never sees or sends them.
3. Rate limits & plan quotas
| Limit | Value | Response when exceeded |
|---|---|---|
| General API traffic | 60 requests / minute, per user | 429 Too Many Requests |
| Ticket creation | 10 requests / minute, per user | 429 Too Many Requests |
| Monthly ticket ceiling | Set per plan โ see pricing | 402 Payment Required |
The monthly ceiling counts tickets created by the Project in a rolling 30-day window, not a calendar month. There's no cap on replying to existing tickets.
4. Endpoints
Create a ticket
POST /api/v1/tickets
Opens a new ticket with its first message, from the user identified by X-User-Id.
| Field | Type | Notes |
|---|---|---|
subject | string, required | Max 200 characters. |
message | string, required | Max 10,000 characters. |
metadata | object, optional | Up to 20 keys. Recognized: email, app_version, platform (ios|android), os_version, device_model, locale. |
attachments[] | files, optional | Up to 5 files, 10 MB each. jpeg,jpg,png,gif,webp,heic,heif,mp4,mov,txt,log. |
curl -X POST https://your-domain.example/api/v1/tickets \ -H "X-Api-Key: tm_your_project_key" \ -H "X-User-Id: user-42" \ -F "subject=Payment failed" \ -F "message=Card was declined at checkout" \ -F "metadata[platform]=ios" \ -F "attachments[]=@screenshot.png"
Returns 201 with the created ticket, including the first message:
{
"data": {
"id": 101,
"subject": "Payment failed",
"status": "open",
"priority": "normal",
"messages": [
{
"id": 501,
"author": "user",
"body": "Card was declined at checkout",
"attachments": [
{ "id": 9, "name": "screenshot.png", "mime_type": "image/png", "size": 84213, "url": "https://.../attachments/9?signature=..." }
],
"created_at": "2026-08-18T10:15:00+00:00"
}
],
"created_at": "2026-08-18T10:15:00+00:00",
"last_message_at": "2026-08-18T10:15:00+00:00"
}
}
List tickets
GET /api/v1/tickets
Paginated list of the current user's tickets, newest activity first.
| Query param | Notes |
|---|---|
per_page | Default 20, capped at 50. |
Each ticket includes unread_count โ agent replies the user hasn't seen yet โ but not the message thread itself; fetch a single ticket for that.
Show a ticket
GET /api/v1/tickets/{ticket}
Returns the ticket with its full public message thread and attachments. Internal agent notes are never included in this response โ they don't exist for the API at all.
Reply to a ticket
POST /api/v1/tickets/{ticket}/messages
| Field | Type | Notes |
|---|---|---|
body | string, required | Max 10,000 characters. |
attachments[] | files, optional | Same rules as ticket creation. |
Returns 201 with the new message. Returns 422 if the ticket's status is closed โ open a new ticket instead.
Mark a ticket read
POST /api/v1/tickets/{ticket}/read
Marks every agent message currently in the ticket as read for this user. No body. Returns {"data": {"read": true}}.
Unread message count
GET /api/v1/unread-count
Total unread agent replies across all of this user's tickets โ the number to badge your support icon with. Returns {"data": {"unread_count": 3}}.
5. Errors
| Status | Meaning |
|---|---|
401 | Missing/invalid X-Api-Key, or missing/blank X-User-Id. |
402 | The Project's plan has hit its monthly ticket ceiling. |
404 | The ticket doesn't exist or doesn't belong to this user. |
422 | Validation failed, or you tried to reply to a closed ticket. Body: {"message": "...", "errors": {...}}. |
429 | Rate limit exceeded โ see section 3. |
6. Webhooks
Turn these on from the project portal (webhook URL + secret). HelpZeno calls your URL when either of these happens:
| Event | Fires when |
|---|---|
message.created | An agent replies to a ticket (user replies don't trigger this โ you already know about those, you sent them). |
ticket.status_changed | A ticket's status changes, e.g. an agent marks it Resolved. |
Example payload for message.created:
{
"event": "message.created",
"ticket": { "id": 101, "subject": "Payment failed", "status": "open", "priority": "normal", "user_external_id": "user-42" },
"message": { "id": 512, "body": "Refunded โ should land in 3-5 days.", "created_at": "2026-08-18T11:02:00+00:00" }
}
Verifying the signature
Every delivery carries a Signature header: an HMAC-SHA256 hex digest of the raw JSON body,
keyed with the Project's webhook secret. Recompute it and compare before trusting a payload:
$expected = hash_hmac('sha256', $rawRequestBody, $projectWebhookSecret);
if (! hash_equals($expected, $request->header('Signature'))) {
abort(401);
}
Deliveries that fail (non-2xx response, timeout, or connection error) are retried automatically up to 3 times with exponential backoff. Every attempt โ success or failure, with the response HelpZeno received โ is logged and visible on the project portal, so a flaky webhook is easy to diagnose.
7. Attachments
Files returned in API responses (attachments[].url) are signed, time-limited URLs โ they work
without an X-Api-Key header, but expire and can't be reused to enumerate other files. Don't
cache them long-term; re-fetch the ticket if a link has gone stale.