Problem
The v1 API has no endpoint for ticket replies. Third-party clients that want to add a comment, log time, or transition a ticket's status currently have to drive the agent web UI directly — login, follow redirects, scrape a CSRF token out of a rendered HTML form, maintain a cookie jar, and POST to agent/post.php. That works, but it has real cost:
- 2FA on the agent account is hard-blocked. Web automation can't pass through MFA, so any deployment with 2FA enabled loses programmatic reply/time-logging entirely.
- Brittle to UI changes. Renaming a hidden form field or changing the CSRF emit path breaks every client without a version signal.
- Session lifecycle on top of a stateless API. Clients have to handle 302→login redirects, re-auth mid-stream, and per-instance accent/page quirks. None of that exists for the rest of the v1 surface.
I hit all three writing a mobile client (Flutter, talking to v1 API). Same friction applies to any integration that wants to post replies without screen-scraping.
Proposal
Two new endpoints under api/v1/tickets/replies/, no breaking change to existing routes:
GET read.php — list replies. reply_id=<n> → single row; ticket_id=<n> → all replies on a ticket (newest first, LIMIT/OFFSET); no filter → all replies in the API key's client scope.
POST create.php — insert a reply and/or change status and/or log time worked. Mirrors the data-side behaviour of agent/post/ticket.php's add_ticket_reply (minus CSRF and session-user concerns). Defaults to Internal so an integration that forgets public_reply_type doesn't silently leak its writes to the customer portal. Rejects public_reply_type=2 (Public + email) because the API doesn't dispatch mail — accepting it would let callers think they emailed the customer when they didn't.
Status-only calls ({status: <id>} with no body) are supported so an integration can transition/close a ticket without writing a comment; the response uses insert_id = 0 to signal that case. Scoping uses ticket_client_id LIKE '$client_id', matching the existing tickets/read.php guard so global and scoped keys both work.
Patch
Test plan I ran
Ran against a local dev instance:
GET .../replies/read.php returns rows newest-first whether called with no filter, with ?ticket_id=<id>, or with ?reply_id=<id>; count and row shape match the rest of the v1 read endpoints.
POST create.php with just ticket_reply inserts an Internal row; public_reply_type=1 inserts as Public; =2 is rejected with the documented helper message and writes nothing.
hours/minutes/seconds populate ticket_reply_time_worked (verified e.g. 01:02:03).
POST {ticket_id, status: 4} with no reply body resolves the ticket — ticket_status = 4, ticket_resolved_at = NOW(), response carries insert_id = 0.
- Missing
ticket_id returns "ticket_id is required."; passing neither a body nor a status > 0 returns the documented two-of error.
POST create.php against a ticket outside the API key's client scope is rejected by the existing ticket_client_id LIKE '$client_id' guard.
- First Public reply on a ticket with
ticket_first_response_at IS NULL stamps it (matches the web handler's behaviour).
Problem
The v1 API has no endpoint for ticket replies. Third-party clients that want to add a comment, log time, or transition a ticket's status currently have to drive the agent web UI directly — login, follow redirects, scrape a CSRF token out of a rendered HTML form, maintain a cookie jar, and POST to
agent/post.php. That works, but it has real cost:I hit all three writing a mobile client (Flutter, talking to v1 API). Same friction applies to any integration that wants to post replies without screen-scraping.
Proposal
Two new endpoints under
api/v1/tickets/replies/, no breaking change to existing routes:GET read.php— list replies.reply_id=<n>→ single row;ticket_id=<n>→ all replies on a ticket (newest first,LIMIT/OFFSET); no filter → all replies in the API key's client scope.POST create.php— insert a reply and/or change status and/or log time worked. Mirrors the data-side behaviour ofagent/post/ticket.php'sadd_ticket_reply(minus CSRF and session-user concerns). Defaults to Internal so an integration that forgetspublic_reply_typedoesn't silently leak its writes to the customer portal. Rejectspublic_reply_type=2(Public + email) because the API doesn't dispatch mail — accepting it would let callers think they emailed the customer when they didn't.Status-only calls (
{status: <id>}with no body) are supported so an integration can transition/close a ticket without writing a comment; the response usesinsert_id = 0to signal that case. Scoping usesticket_client_id LIKE '$client_id', matching the existing tickets/read.php guard so global and scoped keys both work.Patch
api/v1/tickets/replies/create.php,api/v1/tickets/replies/read.php(+209 / -0)Test plan I ran
Ran against a local dev instance:
GET .../replies/read.phpreturns rows newest-first whether called with no filter, with?ticket_id=<id>, or with?reply_id=<id>;countand row shape match the rest of the v1 read endpoints.POST create.phpwith justticket_replyinserts an Internal row;public_reply_type=1inserts as Public;=2is rejected with the documented helper message and writes nothing.hours/minutes/secondspopulateticket_reply_time_worked(verified e.g.01:02:03).POST {ticket_id, status: 4}with no reply body resolves the ticket —ticket_status = 4,ticket_resolved_at = NOW(), response carriesinsert_id = 0.ticket_idreturns"ticket_id is required."; passing neither a body nor astatus > 0returns the documented two-of error.POST create.phpagainst a ticket outside the API key's client scope is rejected by the existingticket_client_id LIKE '$client_id'guard.ticket_first_response_at IS NULLstamps it (matches the web handler's behaviour).