HelpZeno

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:

HeaderWhat it identifies
X-Api-KeyWhich Project (tenant) this request belongs to. Find/regenerate it on the project portal.
X-User-IdWhich 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

LimitValueResponse when exceeded
General API traffic60 requests / minute, per user429 Too Many Requests
Ticket creation10 requests / minute, per user429 Too Many Requests
Monthly ticket ceilingSet per plan โ€” see pricing402 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.

FieldTypeNotes
subjectstring, requiredMax 200 characters.
messagestring, requiredMax 10,000 characters.
metadataobject, optionalUp to 20 keys. Recognized: email, app_version, platform (ios|android), os_version, device_model, locale.
attachments[]files, optionalUp 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 paramNotes
per_pageDefault 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

FieldTypeNotes
bodystring, requiredMax 10,000 characters.
attachments[]files, optionalSame 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

StatusMeaning
401Missing/invalid X-Api-Key, or missing/blank X-User-Id.
402The Project's plan has hit its monthly ticket ceiling.
404The ticket doesn't exist or doesn't belong to this user.
422Validation failed, or you tried to reply to a closed ticket. Body: {"message": "...", "errors": {...}}.
429Rate 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:

EventFires when
message.createdAn agent replies to a ticket (user replies don't trigger this โ€” you already know about those, you sent them).
ticket.status_changedA 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.