Skip to content

API: add ticket replies endpoints (avoid scraping agent UI to post replies / log time) #1283

Description

@BoredManCodes

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions