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