Skip to main content

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.

ParameterDescription
stateComma-separated states: new, waiting_on_you, waiting_on_customer, on_hold, closed. Defaults to all
updated_sinceOnly tickets changed after this time. RFC 3339 with an offset, e.g. 2026-08-12T00:00:00Z
created_sinceOnly tickets raised after this time. RFC 3339 with an offset
limitTickets per page, 1–100. Defaults to 50
cursorCursor 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,
"requester": { "name": "Casey Customer", "email": "[email protected]" },
"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.

FieldMeaning
body_htmlThe description the ticket was raised with. It is not sanitized, so sanitize before display
sourceWhere the ticket was raised, e.g. chat_widget, email, form
updated_atThe last change of any kind, including ones made on our side that you won't see
first_responded_atWhen our support team first replied
resolved_atWhen the ticket was last resolved; a reopened ticket's is cleared until it's resolved again
requesterWho 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:

FieldMeaning
breaches_atWhen the goal breaches, or breached
met_atWhen the goal was met, null while it's running
secondsElapsed time to meet the goal
business_hours_secondsElapsed business hours to meet the goal
breachedMet 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.

ParameterDescription
limitMessages per page, 1–100. Defaults to 50
cursorCursor 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://…",
"email": "[email protected]"
}
},
{
"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:

HeaderMeaning
ratelimit-limitCalls available per window
ratelimit-remainingCalls left in the current window
ratelimit-resetSeconds 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​

StatuserrorMeaning
400BadRequestA parameter was malformed, or a cursor expired — check the message
401—Missing or invalid API key
404NotFoundNo such ticket, or it belongs to another organization
404SupportNotConfiguredYour organization isn't on Pylon support yet — contact [email protected]
429TooManyRequestsYour organization's budget is spent
502UpstreamErrorOur ticketing provider could not be reached
503UpstreamRateLimitedOur ticketing provider is throttling us

Atlassian Jira​

caution

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.

ParameterDescription
updated_sinceOnly tickets changed at or after this time. RFC 3339 with an offset, e.g. 2026-08-12T00:00:00Z
created_sinceOnly tickets raised at or after this time. RFC 3339 with an offset
statusopen, closed, or all. Defaults to all
limitTickets per page, 1–100. Defaults to 50
next_page_tokenCursor 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 key together with fields.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.

ParameterDescription
startIndex of the first comment. Defaults to 0
limitComments 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:

HeaderMeaning
ratelimit-limitCalls available per window
ratelimit-remainingCalls left in the current window
ratelimit-resetSeconds 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​

StatuserrorMeaning
400BadRequestA parameter was malformed — check the message
401—Missing or invalid API key
404NotFoundNo such ticket, or it belongs to another organization
404SupportNotConfiguredYour organization isn't linked to a support project yet — contact [email protected]
429TooManyRequestsYour organization's budget is spent
502UpstreamErrorOur ticketing provider could not be reached
503UpstreamRateLimitedOur ticketing provider is throttling us