---
title: "JSON API — Tickets for Laravel | Roundly"
description: "An opt-in JSON API — routes, request bodies, bulk actions, role-aware resources, list filters and sorts, rate limits and error codes."
url: https://roundly-consulting.com/open-source/docs/tickets-for-laravel/http-api
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Tickets for Laravel](https://roundly-consulting.com/open-source/docs/tickets-for-laravel.md)

# JSON API

Routes, form requests, controllers, role-aware resources, query-builder list filters, a policy, rate limits and guest endpoints — off until you switch them on:

```bash
TICKETS_ROUTES=on
```

- Prefix tickets.routes.prefix (api), route names tickets.\*, middleware tickets.routes.middleware (\['api', 'auth', 'throttle:tickets-api'\]).
- {ticket} is the reference (\[A-Z\]\[A-Z0-9\]{0,9}-\[0-9\]+). Controllers look tickets up through the model seam, so the API works without SubstituteBindings and with a swapped ticket model. A soft-deleted ticket is 404, except on restore.
- To change a route, publish routes/tickets.php (--tag=tickets-routes); the published copy replaces the package’s for good.

## With Sanctum

```php
// config/tickets.php
'routes' => [
    'enabled' => env('TICKETS_ROUTES') ?: 'off',
    'middleware' => ['api', 'auth:sanctum', 'throttle:tickets-api'],
    // …
],
```

Any guard works the same way (auth:api, …). Without an auth middleware every endpoint would answer anonymous callers — tickets:doctor warns.

## Rate limits

Defined only with the routes on, applied only while their throttle: middleware is listed: tickets-api allows tickets.routes.rate\_limit (120) requests a minute per signed-in user (per IP otherwise); tickets-guest allows tickets.routes.guest.rate\_limit (30) a minute per IP and ticket.

## Routes

| Method & path | Name | Ability | Notes |
| --- | --- | --- | --- |
| `GET /tickets` | `index` | `viewAny` | Scoped by visibleTo; filters, sorts and paging below. |
| `POST /tickets` | `store` | `create` | 201 |
| `POST /tickets/bulk` | `bulk` | per ticket | Bulk actions, below. |
| `GET /tickets/{ticket}` | `show` | `view` | Detailed: plus field values and attachments. |
| `PATCH /tickets/{ticket}` | `update` | view + update (with the touched attributes) | — |
| `DELETE /tickets/{ticket}` | `destroy` | `view + delete` | Soft; 204. |
| `POST /tickets/{ticket}/restore` | `restore` | `view + restore` | — |
| `GET /tickets/{ticket}/transitions` | `transitions.index` | `view` | The transitions this user may run now: name, label, to, to\_label, requires\_reason, payload. |
| `POST /tickets/{ticket}/transitions/{transition}` | `transitions.store` | `view + transition` | {reason?, payload?} |
| `PUT /tickets/{ticket}/assignee` | `assignee.update` | `view + assign` | {assignee: {type, id}} \| {claim: true} |
| `DELETE /tickets/{ticket}/assignee` | `assignee.destroy` | `view + unassign` | — |
| `GET /tickets/{ticket}/comments` | `comments.index` | view (+ viewInternal with ?internal=1) | — |
| `POST /tickets/{ticket}/replies` | `replies.store` | reply (+ transition) | {body, channel?, attachments\[\]?, transition?, reason?, payload?}; 201 — also when the host’s moderation holds the reply, which then applies no transition. |
| `POST /tickets/{ticket}/notes` | `notes.store` | `view + note` | {body, channel?, attachments\[\]?}; 201 |
| `PATCH /tickets/{ticket}/comments/{comment}` | `comments.update` | `view + updateComment` | {body}; the comment must be this ticket’s; recorded as comment\_edited. |
| `DELETE /tickets/{ticket}/comments/{comment}` | `comments.destroy` | `view + deleteComment` | 204; recorded as comment\_deleted. |
| `GET /tickets/{ticket}/fields` | `fields.index` | `view` | Role-filtered. |
| `PUT /tickets/{ticket}/fields` | `fields.update` | view + update (fields) | {fields: {key: value}}; null clears a value. |
| `GET /tickets/{ticket}/watchers` | `watchers.index` | `view + manageWatchers` | Agents only. |
| `POST /tickets/{ticket}/watchers` | `watchers.store` | `view + manageWatchers` | {watcher: {type, id}}; 201 |
| `DELETE /tickets/{ticket}/watchers/{alias:key}` | `watchers.destroy` | `view + manageWatchers` | 204 |
| `POST / DELETE /tickets/{ticket}/watch` | `watch / unwatch` | view + manageWatchers (self) | 204 |
| `PUT /tickets/{ticket}/tags` | `tags.sync` | `view + tag` | {tags: \[names\]} |
| `GET /tickets/{ticket}/links` | `links.index` | `view + link` | — |
| `POST /tickets/{ticket}/links` | `links.store` | view + link on both tickets | {ticket: reference, type}; 201 |
| `DELETE /tickets/{ticket}/links/{link}` | `links.destroy` | `view + link` | 204; removes exactly that row, from either end. |
| `POST /tickets/{ticket}/merge` | `merge` | view + merge on the source and on the target | {target: reference, reason?} |
| `GET /tickets/{ticket}/attachments` | `attachments.index` | `view` | Short-lived signed URLs. |
| `POST /tickets/{ticket}/attachments` | `attachments.store` | view + update (attachments) — agents | Multipart file; 201. |
| `GET /tickets/{ticket}/attachments/{media}` | `attachments.show` | `view` | — |
| `DELETE /tickets/{ticket}/attachments/{media}` | `attachments.destroy` | view + update (attachments) | 204 |
| `GET /tickets/{ticket}/activity` | `activity` | `view` | Role-filtered. |
| `GET / POST /ticket-categories` | `admin.categories.index / .store` | `viewAny / manageCategories` | The tree; ?project=alias:key; inactive categories (with their subtrees) for agents only. |
| `GET / PATCH / DELETE /ticket-categories/{id}` | `admin.categories.show / .update / .destroy` | `viewAny / manageCategories` | An inactive category is 404 category\_not\_found for non-agents; delete takes {reassign\_to?}. |
| `POST /ticket-categories/{id}/move` | `admin.categories.move` | `manageCategories` | {parent\_id, position?} |
| `GET / POST /ticket-fields` | `admin.fields.index / .store` | `viewAny / manageFields` | ?project=, ?category=; role-filtered. |
| `GET / PATCH / DELETE /ticket-fields/{id}` | `admin.fields.show / .update / .destroy` | `viewAny / manageFields` | — |
| `GET / POST /ticket-tags` | `admin.tags.index / .store` | viewTags (agents) / manageTags | — |
| `PATCH / DELETE /ticket-tags/{id}` | `admin.tags.update / .destroy` | `manageTags` | — |
| `GET /tickets/guest/{ticket}` | `guest.show` | signed link | See Guests and guest links. |
| `POST /tickets/guest/{ticket}/replies` | `guest.reply` | signed link | — |
| `POST /tickets/guest/{ticket}/transitions/{transition}` | `guest.transition` | signed link | Requester transitions only. |
| `GET /tickets/guest/{ticket}/attachments/{media}` | `guest.attachment` | signed link | The ticket’s files and its public thread’s. |

There is no category-restore endpoint — use Tickets::categories()->restore().

## Filing a ticket

```json
POST /api/tickets
{
  "subject": "Printer on floor 3 is jammed",
  "description": "Paper jam error E-102.",
  "category_id": 4,
  "priority": "high",
  "channel": "web",
  "fields": {"asset_tag": "PRN-0042"},
  "project": {"type": "project", "id": 1}
}
```

Self-service files for the signed-in user. subject (≤ 255), description, category\_id, priority, channel, project and fields (requester-editable ones for a non-agent) are open to anyone who may file. requester, guest (fields, instead of a requester), assignee, due\_at (after now), tags and watchers are agent-only inputs; naming an assignee other than yourself needs the assign ability. Morph inputs are {type: alias, id} with an alias from the allow-lists — a class name, an unknown alias or a key that matches no record is 422. Attachments are multipart attachments\[\].

## Bulk

```json
POST /api/tickets/bulk
{"action": "transition", "tickets": ["TCK-1", "TCK-2"], "transition": "resolve", "payload": {"resolution": "Fixed"}}
```

action is assign (assignee or claim: true), unassign, transition (transition, reason?, payload?), priority (priority) or tags (tags.attach / tags.detach, up to 50 names each) — at most tickets.routes.bulk.max (100) distinct references. Each ticket runs on its own policy check and transaction, and one failing never stops the others:

```json
{"data": [
  {"ticket": "TCK-1", "ok": true, "status": 200},
  {"ticket": "TCK-2", "ok": false, "status": 422, "code": "ticket_transition_denied", "message": "…"}
]}
```

A per-ticket validation failure reports code validation\_failed; every other failure carries the code of the error table (ticket\_not\_found for an unknown reference or one the user may not view). assign without assignee and without claim: true is a 422 for the whole request.

## Request bodies

| Endpoint | Body |
| --- | --- |
| `PATCH /tickets/{ticket}` | Any of subject (≤ 255), description (≤ 65535, nullable), priority, category\_id (nullable), due\_at (after now, nullable), requester ({type, id}) — only what is sent changes; host meta is not writable through the API. |
| `POST …/transitions/{transition}` | {reason?: string ≤ 5000, payload?: object}; {transition} matches \[a-z\]\[a-z0-9\_\]{0,63}. |
| `POST …/replies` | {body: string ≤ 65535, channel?, attachments\[\]?, transition?, reason?, payload?} |
| `POST …/notes, POST /tickets/guest/{ticket}/replies` | {body, channel?, attachments\[\]?} |
| `PATCH …/comments/{comment}` | {body} |
| `GET …/comments` | ?internal=1 for the internal thread (agents). |
| `PUT …/fields` | {fields: {key: value, …}} (present; null clears a value). |
| `POST …/watchers` | {watcher: {type, id}}; DELETE …/watchers/{alias}:{key}. |
| `PUT …/tags` | {tags: \[name, …\]} (present, ≤ 50, each ≤ 64) — replaces the set. |
| `POST …/links` | {ticket: reference, type: relates\_to \| duplicates \| blocks \| parent\_of} |
| `POST …/merge` | {target: reference, reason?: string ≤ 5000} |
| `POST …/attachments` | Multipart file. |
| `GET /tickets` | per\_page (1 – max\_per\_page), page, filter\[…\], sort |

Uploads (attachments\[\], file) follow the bucket’s own mime types and size cap through media-library — tickets.attachments.\* for ticket files, comments’ comments.media.\* for message files — at most 10 files per request.

## Admin endpoints

Translatable text (name, description, label, help, an option label) is a string in the current locale or a locale map ({"en": "Hardware", "sk": "Hardvér"}). Morph inputs are {type: alias, id}; list filters take alias:key.

| Endpoint | Body / query |
| --- | --- |
| `GET /ticket-categories` | ?project=alias:key — the global tree plus that project’s categories, roots first, children nested. |
| `POST /ticket-categories` | {name, description?, parent\_id?, project?: {type, id}, position?, is\_active?, default\_priority?, default\_assignee?: {type, id}, default\_due\_in?: ISO-8601 (PT4H)} |
| `PATCH /ticket-categories/{id}` | Any of name, description, position, is\_active, default\_priority, default\_assignee, default\_due\_in (null clears description or a default); the parent moves through /move, the project never changes. |
| `POST /ticket-categories/{id}/move` | {parent\_id: id \| null, position?} (parent\_id must be present; null = to the root). |
| `DELETE /ticket-categories/{id}` | {reassign\_to?: id} — needed while live tickets use the category. |
| `GET /ticket-fields` | ?project=alias:key, ?category=id (the set a ticket of that category would get). Non-agents see active, requester-visible fields only. |
| `POST /ticket-fields` | {key, type, label, help?, options?: {value: label}, rules?: \[string\], required?, encrypted?, default?, requester\_visible?, requester\_editable?, position?, is\_active?, project?: {type, id}, category\_id?} — at most 200 options and 20 rules. |
| `PATCH /ticket-fields/{id}` | Any of type, label, help, options, rules, required, encrypted, default, requester\_visible, requester\_editable, position, is\_active (key, project and category are fixed). |
| `GET /ticket-tags` | ?project=alias:key — the global tags plus the project’s, by name (agents). |
| `POST /ticket-tags` | {name: string ≤ 64, color?: "#rrggbb", project?: {type, id}} — re-creating a deleted name restores it, on no ticket. |
| `PATCH /ticket-tags/{id}` | Any of name, color (null clears it). |

## Resources

Role-aware: the viewer is an agent (the agent ability and the agent scope allow them on this ticket), the requester, a participant (a watcher) or a guest (through a signed link).

| Resource | Everyone who may view | Agents also get |
| --- | --- | --- |
| `TicketResource` | reference, number, subject, description, status, priority, category ({id, name, slug}), project ({type, id}), requester, assignee, channel, due\_at, resolved\_at, closed\_at, merged\_into (a reference), messages\_count (public messages), created\_at, updated\_at; detailed responses add fields (requester-visible) and attachments. | assigned\_at, first\_responded\_at, last\_activity\_at, tags ({name, slug, color}), the requester in full, every message counted, every field. |
| `TicketCommentResource` | id, body, role, author, channel, attachments, created\_at, updated\_at | internal, status (comments’ moderation status). |
| `TicketActivityResource` | id, type, label, system, actor, properties (morph types as aliases), occurred\_at — public entries only. | visibility, internal entries, raw properties. |
| `TicketLinkResource` | id, type, label, direction (outgoing / incoming), ticket {reference, subject, status} — ticket {reference} only when the viewer may not view the other ticket. | — |
| `TicketAttachmentResource` | id (uuid), name, mime\_type, size, url, created\_at | — |
| `TicketGuestResource` | {type: 'guest', name} — the name only for the guest themselves. | {type: 'guest', id, name, fields} — every guest field; PII stays with agents. |
| `TicketCategoryResource` | id, name, description, slug, parent\_id, project, position, is\_active, children | translations {name, description} (the locale maps), defaults {priority, assignee, due\_in}. |
| `TicketFieldResource` | id, key, type, label, help, options (\[{value, label}\], current locale), required, category\_id, project, position. | translations {label, help}, rules, default, encrypted, requester\_visible, requester\_editable, is\_active |
| `TicketTagResource` | id, name, slug, color, project (only agents reach it). | — |

People (requester, assignee, author, actor, watcher) show as their alias from the allow-lists, never a class name: agents get {type, id, name}; anyone else {type, name}, with name: null for a guest who is not themselves. Requesters never see internal notes, held replies, internal activity, last\_activity\_at, tags or watcher lists, and a ticket’s host meta never appears in any resource. Enum values come with their translated label and colour, so a client renders a badge without its own lookup tables:

```json
"status":   {"value": "pending", "label": "Waiting on requester", "category": "waiting", "color": "amber"},
"priority": {"value": "high", "label": "High", "weight": 30, "color": "orange"},
"channel":  {"value": "web", "label": "Web", "color": "blue"}
```

## Response bodies

| Endpoint | Body |
| --- | --- |
| `GET /tickets` | A paginated TicketResource collection (data, links, meta). |
| `POST /tickets (201), GET / PATCH /tickets/{ticket}, POST …/restore, POST …/transitions/{transition}, PUT / DELETE …/assignee` | The detailed TicketResource. |
| `GET …/transitions` | {data: \[{name, label, to, to\_label, requires\_reason, payload}\]} |
| `GET …/comments` | TicketCommentResource collection, oldest first. |
| `POST …/replies, POST …/notes (201), PATCH …/comments/{comment}` | The TicketCommentResource. |
| `GET / PUT …/fields` | {data: \[{key, label, type, value}\]} — the fields that apply, in order (requester-visible ones for non-agents); value is null when unset. |
| `GET / POST …/watchers (201)` | {data: \[person, …\]} — every watcher, oldest first; a non-agent adding themselves gets their own entry only. |
| `PUT …/tags` | The ticket’s tags, a TicketTagResource collection. |
| `GET / POST …/links (201)` | {data: \[TicketLinkResource, …\]} — every link, read from this ticket. |
| `POST …/merge` | {source: TicketResource, target: TicketResource, moved: {comments, attachments}} |
| `GET …/attachments, GET …/attachments/{media}, POST …/attachments (201)` | TicketAttachmentResource (collection). |
| `GET …/activity` | TicketActivityResource collection, oldest first (\[\] with tickets.activity.enabled off). |
| `POST /tickets/bulk` | {data: \[{ticket, ok, status, code?, message?}\]} |
| admin endpoints | TicketCategoryResource (the tree for the index), TicketFieldResource, TicketTagResource; store answers 201. |
| every other DELETE, POST / DELETE …/watch | 204, no body. |

## Ticket list — filters, sorts, paging

GET /tickets is query-builder-for-laravel over visibleTo($user); every filter is one of the Ticket query scopes:

| Filter | Value | Scope |
| --- | --- | --- |
| `filter[status]` | list of state values | `withStatus` |
| `filter[status_category]` | list: open, active, waiting, resolved, closed | `withStatusCategory` |
| `filter[priority]` | list | `withPriority` |
| `filter[category]` | list of ids; filter\[category\_descendants\]=1 adds subcategories | `inCategory` |
| `filter[assignee]` | list of alias:key, me, none | `assignedTo / unassigned` |
| `filter[requester]` | list of alias:key (agents; anyone else matches only themselves), me | `requestedBy` |
| `filter[project]` | alias:key, none | `inProject` |
| `filter[tag]` | list of slugs — agents only (400 for anyone else) | `taggedWith` |
| `filter[watching]` | me | `watchedBy` |
| `filter[overdue]` | 1 (0 adds nothing) | `overdue` |
| `filter[due_from], filter[due_to], filter[created_from], filter[created_to]` | a date or a datetime (inclusive; a date-only \*\_to such as 2026-10-17 runs to the end of that day) | `dueFrom / dueTo / createdFrom / createdTo` |
| `filter[channel]` | list | `viaChannel` |
| `filter[merged]` | boolean | `merged` |
| `filter[search]` | text — the reference exactly, or subject / description (% and \_ literal) | `search` |
| `filter[fields.<key>]` | list | `whereField` |
| `filter[fields.<key>.from], filter[fields.<key>.to]` | number / decimal / date / datetime fields (inclusive) | `whereFieldBetween` |
| `filter[trashed]` | with, only — holders of the manage ability only | — |

The agent-only filters — tag, requester=alias:key and a field that is not requester-visible — match only the tickets the agent scope lets the viewer work on. An unknown filter or sort is 400 under the host’s query-builder.mode.unknown\_filter = reject (the default); ignore drops it.

Sorts: created\_at, updated\_at, due\_at, number, priority (the enum weights) and, for agents, last\_activity\_at. The default is -last\_activity\_at for agents and -created\_at otherwise. Paging: per\_page (default 20, at most tickets.routes.max\_per\_page) and page; the list eager-loads category, assignee, requester, tags and message counts in a fixed number of queries.

## Errors

Mapped in the package’s controllers — never in your exception handler — to {message, code}. Configuration errors stay 500: they are the host’s to fix, never the client’s.

| Status | code | When |
| --- | --- | --- |
| 400 | (query-builder) | Unknown filter or sort. |
| 403 | `forbidden` | A policy denial; comments’ UnauthorizedCommentActionException (with comments.authorization on). |
| 403 | `access_denied` | Not a participant of the ticket. |
| 403 | `guest_link_invalid` | Any guest link that does not work — one body for all. |
| 404 | `ticket_not_found` | Unknown reference, or a ticket the user may not view (hidden = missing); on every ticket endpoint view is asked first. |
| 404 | `comment_not_found, attachment_not_found, link_not_found, category_not_found, field_not_found, tag_not_found` | A record not of this ticket, or unknown. |
| 409 | `ticket_closed` | A message on a Closed ticket (or comments’ lock). |
| 409 | `ticket_trashed` | A mutation of a deleted ticket. |
| 409 | `field_type_locked, duplicate_reference` | A locked field type; a duplicate project prefix. |
| 422 / 429 | `ticket_transition_denied` | Lifecycle refused (with denials); 429 with Retry-After when a denial says when to retry. |
| 422 | `unknown_transition, unknown_field, field_not_editable, unknown_*_type` | — |
| 422 | `invalid_assignee, invalid_link, invalid_merge, invalid_category, invalid_field, invalid_tag, invalid_watcher, invalid_project` | — |
| 422 | `comment_rejected, invalid_comment_body` | comments refused the body. |
| 422 | Laravel’s validation body | Form request or field-value validation — inputs named in the request’s language; guest fields under guest.<name>. |

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/tickets-for-laravel.md)
