Support Tickets
Read your organization's support tickets programmatically.
Every request is scoped to the organization that owns your API key. There is no organization parameter, and no way to reach another organization's tickets.
Pylon is our support system. Atlassian Jira Service Management is our legacy one: it is being deprecated, and stays available only for a short crossover period while we move every account over to Pylon.
- Pylon, under
/api/v1/pylon, is the API to build on. - Atlassian Jira, under
/api/v1/atlassian, serves tickets from the legacy desk until your account has moved.
Until your account has moved, the Pylon endpoints return
404 SupportNotConfigured.
Pylon
These are the same tickets and conversations you see in the support portal. Responses are TV Labs' own shape rather than the provider's body, and internal fields such as assignment, internal tags and internal notes are never included.
Listing tickets
GET /api/v1/pylon/tickets
Returns your organization's tickets, most recently changed first. Chats that never became tickets are not included.
| Parameter | Description |
|---|---|
state | Comma-separated states: new, waiting_on_you, waiting_on_customer, on_hold, closed. Defaults to all |
updated_since | Only tickets changed after this time. RFC 3339 with an offset, e.g. 2026-08-12T00:00:00Z |
created_since | Only tickets raised after this time. RFC 3339 with an offset |
limit | Tickets per page, 1–100. Defaults to 50 |
cursor | Cursor for the next page, taken from the previous response's next_cursor |
waiting_on_you means the ticket is waiting on our support team;
waiting_on_customer means it is waiting on you.
curl -H "Authorization: Bearer $TVLABS_API_KEY" \
"https://tvlabs.ai/api/v1/pylon/tickets?state=new,waiting_on_you&limit=25"
{
"tickets": [
{
"id": "6c1f0b0e-3a8e-4a7c-9d0e-2f1b5a6c7d8e",
"number": 412,
"title": "Playback stalls on 4K streams",
"body_html": "<p>Playback stalls after about 30 seconds.</p>",
"state": "waiting_on_you",
"source": "chat_widget",
"created_at": "2026-09-01T14:02:11Z",
"updated_at": "2026-09-02T09:40:05Z",
"last_message_at": "2026-09-02T09:40:05Z",
"first_responded_at": "2026-09-01T14:20:37Z",
"resolved_at": null,
"attachment_urls": [],
"slas": [
{
"first_response": {
"breaches_at": "2026-09-01T14:17:11Z",
"met_at": "2026-09-01T14:20:37Z",
"seconds": 1106,
"business_hours_seconds": 1106,
"breached": true
},
"first_resolution": null,
"resolution": {
"breaches_at": "2026-09-03T14:02:11Z",
"met_at": null,
"seconds": null,
"business_hours_seconds": null,
"breached": false
}
}
]
}
],
"next_cursor": "eyJvZmZzZXQiOjI1fQ"
}
Every field is always present, and null when it doesn't apply yet.
| Field | Meaning |
|---|---|
body_html | The description the ticket was raised with. It is not sanitized, so sanitize before display |
source | Where the ticket was raised, e.g. chat_widget, email, form |
updated_at | The last change of any kind, including ones made on our side that you won't see |
first_responded_at | When our support team first replied |
resolved_at | When the ticket was last resolved; a reopened ticket's is cleared until it's resolved again |
requester | Who raised the ticket, or null when that couldn't be read |
first_responded_at and resolved_at are recorded as the ticket moves through
our support team, so tickets carried over from our previous service desk may
have null in both even when closed.
SLAs
slas lists the service-level goals applied to the ticket, and is empty when
none applies. Each entry has up to three timers — first_response,
first_resolution and resolution — and a timer is null when that goal
isn't tracked:
| Field | Meaning |
|---|---|
breaches_at | When the goal breaches, or breached |
met_at | When the goal was met, null while it's running |
seconds | Elapsed time to meet the goal |
business_hours_seconds | Elapsed business hours to meet the goal |
breached | Met after breaches_at, or still running past it. null without a breach time |
first_resolution is the ticket's first resolution and doesn't move if it's
reopened; resolution is its latest.
To page through results, pass next_cursor from one response as cursor on
the next. The last page has a null next_cursor. A cursor that has expired
or been altered returns 400, so don't retry it — start again from the first
page.
Syncing changes
updated_since keeps a local copy current: list the tickets that changed since
your last sync, then fetch the conversation for just those. Advance your cursor
using the largest updated_at you actually received, not your own clock, so a
ticket changed mid-sync isn't skipped.
Fetching one ticket
GET /api/v1/pylon/tickets/:id
Takes the ticket's id or its number, and returns it under ticket in the
same shape as the listing.
curl -H "Authorization: Bearer $TVLABS_API_KEY" \
https://tvlabs.ai/api/v1/pylon/tickets/412
Tickets that belong to another organization return 404, the same as a ticket
that doesn't exist.
Fetching a ticket's messages
GET /api/v1/pylon/tickets/:id/messages
Returns the public conversation on a ticket, oldest first. Internal notes between our support engineers are never included.
| Parameter | Description |
|---|---|
limit | Messages per page, 1–100. Defaults to 50 |
cursor | Cursor for the next page, taken from the previous response's next_cursor |
curl -H "Authorization: Bearer $TVLABS_API_KEY" \
https://tvlabs.ai/api/v1/pylon/tickets/412/messages
{
"messages": [
{
"id": "b7e1…",
"message_html": "<p>Playback stalls after about 30 seconds.</p>",
"sent_at": "2026-09-01T14:02:11Z",
"source": "chat_widget",
"file_urls": [],
"author": {
"type": "customer",
"name": "Casey Customer",
"avatar_url": "https://…",
}
},
{
"id": "c9a4…",
"message_html": "<p>Thanks — we're looking into it.</p>",
"sent_at": "2026-09-01T14:20:37Z",
"source": "chat_widget",
"file_urls": [],
"author": { "type": "support", "name": "Sam", "avatar_url": "https://…" }
}
],
"next_cursor": null
}
An author.type of support is a TV Labs support engineer; only their name and
avatar are shown.
Internal notes are removed after the page is fetched, so a page can hold fewer
messages than limit, even none. Keep paging while next_cursor is set.
Rate limits
These endpoints draw on a quota we share with our ticketing provider, so each organization gets a budget of upstream calls per minute. Listing a page and fetching one ticket each cost one call; fetching a ticket's messages costs two.
Filling in a ticket's requester takes one more call for each person we
haven't looked up recently. That is charged after the response, so a first
sync over many requesters spends the budget faster; later pages mostly reuse
what earlier ones read.
Every response carries your current standing:
| Header | Meaning |
|---|---|
ratelimit-limit | Calls available per window |
ratelimit-remaining | Calls left in the current window |
ratelimit-reset | Seconds until the window resets |
Exceeding the budget returns 429 with a retry-after header. If our provider
throttles us, you'll get 503, also with retry-after. Back off and retry in
both cases.
Errors
| Status | error | Meaning |
|---|---|---|
| 400 | BadRequest | A parameter was malformed, or a cursor expired — check the message |
| 401 | — | Missing or invalid API key |
| 404 | NotFound | No such ticket, or it belongs to another organization |
| 404 | SupportNotConfigured | Your organization isn't on Pylon support yet — contact [email protected] |
| 429 | TooManyRequests | Your organization's budget is spent |
| 502 | UpstreamError | Our ticketing provider could not be reached |
| 503 | UpstreamRateLimited | Our ticketing provider is throttling us |
Atlassian Jira
These endpoints are deprecated. They serve tickets from our legacy Atlassian service desk during the crossover period while accounts move to Pylon, and will be removed once that migration is done. Build new integrations on the Pylon endpoints.
These endpoints pass the provider's responses through unchanged, which is why
they live under /api/v1/atlassian.
Listing tickets
GET /api/v1/atlassian/issues
Returns your organization's tickets, most recently changed first.
| Parameter | Description |
|---|---|
updated_since | Only tickets changed at or after this time. RFC 3339 with an offset, e.g. 2026-08-12T00:00:00Z |
created_since | Only tickets raised at or after this time. RFC 3339 with an offset |
status | open, closed, or all. Defaults to all |
limit | Tickets per page, 1–100. Defaults to 50 |
next_page_token | Cursor for the next page, taken from the previous response |
curl -H "Authorization: Bearer $TVLABS_API_KEY" \
"https://tvlabs.ai/api/v1/atlassian/issues?status=open&limit=25"
The response is the provider's search body:
{
"issues": [
{
"key": "TVL-675",
"fields": {
"summary": "Unable to log in with valid credentials",
"status": { "name": "Pending Customer Response" },
"created": "2026-08-11T07:29:49.471-0400",
"updated": "2026-08-12T04:57:37.772-0400",
"reporter": { "displayName": "Kiran Reddy" },
"customfield_10051": { "name": "Time to first response", "completedCycles": [] }
}
}
],
"names": { "customfield_10051": "Time to first response" },
"nextPageToken": "CAEaAggD"
}
Ticket fields keep the provider's names, including opaque ids like
customfield_10051. The names object maps those ids to their display names,
so you can resolve them at runtime instead of hard-coding them. It sits at the
top level of the response on this endpoint and on /issues/bulk, alongside
issues; on the single-ticket endpoint it sits alongside that ticket's own
fields.
To page through results, pass the nextPageToken from one response as
next_page_token on the next request. The last page has no nextPageToken.
A cursor that has expired or been altered returns 400 rather than 502, so
don't retry it — start the walk again from the first page.
Descriptions and comments are not included
Listing responses carry the ticket's metadata and SLA state but not its description or conversation, so walking a large backlog stays fast. Fetch those per ticket once you know which ones you care about.
Syncing changes
updated_since is the intended way to keep a local copy current: list the
tickets that changed since your last sync, then fetch the detail for just those.
curl -H "Authorization: Bearer $TVLABS_API_KEY" \
"https://tvlabs.ai/api/v1/atlassian/issues?updated_since=2026-08-12T00:00:00Z"
Two things to know:
- Comparisons are minute-accurate. A window may return a ticket you already
hold. De-duplicate on
keytogether withfields.updated. - Timestamps carry the provider's offset, as in the example above. Parse them as RFC 3339 rather than assuming UTC.
Advance your cursor using the largest fields.updated you actually received,
not your own clock, so a ticket changed mid-sync isn't skipped.
Fetching many tickets at once
POST /api/v1/atlassian/issues/bulk
Fetches up to 100 tickets by key in a single request, each carrying everything
the single-ticket endpoint returns, descriptions included. This is the companion
to updated_since: take the keys that changed and pull them in one round trip
instead of one request per ticket.
curl -X POST \
-H "Authorization: Bearer $TVLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"keys": ["TVL-675", "TVL-676"]}' \
https://tvlabs.ai/api/v1/atlassian/issues/bulk
{
"issues": [
{
"key": "TVL-675",
"fields": { "summary": "Unable to log in with valid credentials" }
},
{
"key": "TVL-676",
"fields": { "summary": "Playback stalls on 4K streams" }
}
],
"names": { "customfield_10051": "Time to first response" }
}
One names map covers every ticket in the response, at the top level alongside
issues, the same as on the listing endpoint.
Tickets that don't exist, and tickets belonging to another organization, are
absent from issues; the two cases are indistinguishable. Diff the keys you
asked for against the keys you got back to find the gaps.
Comments aren't included — fetch those per ticket.
Fetching one ticket
GET /api/v1/atlassian/issues/:key
Returns a single ticket including its description, plus everything the listing
carries.
curl -H "Authorization: Bearer $TVLABS_API_KEY" \
https://tvlabs.ai/api/v1/atlassian/issues/TVL-675
Tickets that belong to another organization return 404, the same as a ticket
that doesn't exist.
Fetching a ticket's comments
GET /api/v1/atlassian/issues/:key/comments
Returns the public conversation on a ticket. Internal notes between our support engineers are never included.
| Parameter | Description |
|---|---|
start | Index of the first comment. Defaults to 0 |
limit | Comments per page, 1–100. Defaults to 50 |
curl -H "Authorization: Bearer $TVLABS_API_KEY" \
https://tvlabs.ai/api/v1/atlassian/issues/TVL-675/comments
Use isLastPage in the response to decide whether to request another page.
Rate limits
These endpoints draw on a quota we share with our ticketing provider, so each organization gets a budget of upstream calls per minute. Listing a page and fetching one ticket each cost one call; fetching a batch, or a ticket's comments, costs two.
Batching is the cheapest way to stay inside the budget: one hundred tickets cost
two calls through /issues/bulk and a hundred calls one at a time.
Every response carries your current standing:
| Header | Meaning |
|---|---|
ratelimit-limit | Calls available per window |
ratelimit-remaining | Calls left in the current window |
ratelimit-reset | Seconds until the window resets |
Exceeding the budget returns 429 with a retry-after header. If our provider
throttles us, you'll get 503, also with retry-after. Back off and retry in
both cases; a sync that pauses for the stated interval will finish.
Errors
| Status | error | Meaning |
|---|---|---|
| 400 | BadRequest | A parameter was malformed — check the message |
| 401 | — | Missing or invalid API key |
| 404 | NotFound | No such ticket, or it belongs to another organization |
| 404 | SupportNotConfigured | Your organization isn't linked to a support project yet — contact [email protected] |
| 429 | TooManyRequests | Your organization's budget is spent |
| 502 | UpstreamError | Our ticketing provider could not be reached |
| 503 | UpstreamRateLimited | Our ticketing provider is throttling us |