Ticket Replies

7 views Markdown

The eight endpoints that read and write a ticket conversation and set how replies are kept.

Overview

A ticket's conversation lives at these eight endpoints. Four deal with the reply itself, two change how a reply is stored and seen, and two handle attachments.

Replies are walked with a cursor rather than a page number. The newest comes first; to reach older ones you hand back the oldest id you hold, and to poll for new ones you hand back the newest.

Two different ideas of privacy live here and should not be confused. Encryption is about how the text sits in the database, and staff still read it. Hiding decides whether the client sees it at all.

Reference

Listing the Replies

get/api/v1/admin/tickets/{id}/messages
Tickets/GetTicketMessages admin cursor paging

Returns a ticket's conversation, newest first.

Query 3
before_reply_idintFetches what came before this reply. This is how you page backwards.
after_reply_idintFetches what came after this reply. This is how you poll for new ones.
limitintHow many replies to return. Clamped between one and fifty, ten by default.
Response fields data[] — 15 + meta — 5
idintThe reply id.
author_idintThe author id.
author_namestringThe author name.
is_adminboolWhether staff wrote it.
messagestringThe reply text. It arrives decoded even when stored encrypted.
encryptedboolWhether it is stored encrypted.
hiddenboolWhether it is hidden from the client. Hidden replies come back in this list too.
pipeboolWhether it arrived by e-mail.
aiboolWhether an assistant wrote it.
contactboolWhether it came from the contact form.
ratingint | nullThe score the client gave.
rated_atstring | nullWhen it was scored.
ipstring | nullThe sender address.
created_atstring | nullWhen it was written.
attachmentsarrayThe attachments: id, shown name, stored name and size. The content lives at the download endpoint rather than here.
totalintHow many replies the ticket holds. It comes back under meta.
countintHow many came back on this page.
oldest_reply_idintThe oldest reply on the page. Hand this to the next backwards page.
newest_reply_idintThe newest reply on the page. Hand this to your polling call.
has_moreboolWhether older ones remain.
Errors 2
not_found404No such ticket or reply.
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/tickets/402/messages?limit=10' \
  -H "Authorization: Bearer $API_KEY"
const url = new URL(`https://panel.example.com/api/v1/admin/tickets/${id}/messages`);
url.searchParams.set('limit', '10');

const res  = await fetch(url, {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const { data, meta } = await res.json();

// Bir sonraki sayfa: url.searchParams.set('before_reply_id', meta.oldest_reply_id)
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages?' . http_build_query(['limit' => 10]));
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// THERE IS NO PAGE NUMBER: you ask for the next page with the oldest id you hold.
$page = Api::Tickets()->GetTicketMessages(['id' => $id], ['limit' => 10]);
$next = Api::Tickets()->GetTicketMessages(['id' => $id], [
    'before_reply_id' => $page['meta']['oldest_reply_id'],
]);

Writing a Reply

post/api/v1/admin/tickets/{id}/messages
Tickets/AddTicketMessage admin reaches the client

Adds a reply, moves the ticket to replied and tells the client.

Body 7
messagestringreqThe reply text.
signaturestringA signature appended to the text.
encryptboolStores the reply encrypted.
attachmentsstring | object | arrayFiles to attach.
aiboolMarks the reply as written by an assistant.
hiddenboolAdds it as a staff-only reply. The client never sees it, the status stays put and no notice goes out.
author_namestringThe author name shown to the client. Left out, the key's owner appears.
Response fields 201 — data
dataobjectThe reply added. Same shape as a list item.
Errors 6
ticket_locked422The ticket is locked.
message_required422The message is empty.
blocked_by_gate422A hook refused the reply.
reply_failed422The reply could not be added.
not_found404No such ticket or reply.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/tickets/402/messages' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"message":"Sorununuz cozuldu.","signature":"Destek Ekibi"}'
const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/messages`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    message: 'Your issue is resolved. Please confirm.',
    signature: 'Support Team',
  }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'message'   => 'Your issue is resolved. Please confirm.',
        'signature' => 'Support Team',
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// To leave a note, add it HIDDEN: otherwise the client gets an e-mail.
Api::Tickets()->AddTicketMessage([
    'id'      => $id,
    'message' => 'Checked the server logs, nothing unusual.',
    'hidden'  => true,
]);

Editing a Reply

patch/api/v1/admin/tickets/{id}/messages/{reply_id}
Tickets/UpdateTicketMessage admin

Changes the text of a reply already written.

Body 1
messagestringreqThe new text. An encrypted reply stays encrypted.
Response fields data
dataobjectThe reply as it now reads. Same shape as a list item.
Errors 3
message_required422The message is empty.
not_found404No such ticket or reply.
insufficient_scope403The key lacks the required scope.
Request
curl -X PATCH 'https://panel.example.com/api/v1/admin/tickets/402/messages/846' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"message":"Duzeltilmis yanit metni."}'
const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/messages/${replyId}`, {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ message: 'Updated reply text.' }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages/' . $replyId);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'PATCH',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['message' => 'Updated reply text.']),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Editing TELLS THE CLIENT NOTHING: the old text stands in the mail already sent.
Api::Tickets()->UpdateTicketMessage([
    'id'       => $id,
    'reply_id' => $replyId,
    'message'  => 'Updated reply text.',
]);

Deleting a Reply

delete/api/v1/admin/tickets/{id}/messages/{reply_id}
Tickets/DeleteTicketMessage admin

Removes a reply along with its attachments.

Response fields data — 2
deletedboolWhether the delete ran.
reply_idintThe id of the reply removed.
Errors 2
not_found404No such ticket or reply.
insufficient_scope403The key lacks the required scope.
Request
curl -X DELETE 'https://panel.example.com/api/v1/admin/tickets/402/messages/846' \
  -H "Authorization: Bearer $API_KEY"
const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/messages/${replyId}`, {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages/' . $replyId);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'DELETE',
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// The reply's attachments go too, and the ticket's last-reply summary shifts on the next read.
Api::Tickets()->DeleteTicketMessage(['id' => $id, 'reply_id' => $replyId]);

Changing the Encryption

put/api/v1/admin/tickets/{id}/messages/{reply_id}/encryption
Tickets/SetTicketMessageEncryption admin

Decides whether a reply is stored encrypted.

Body 1
encryptedboolreqStores it encrypted, or takes the encryption off.
Response fields data — 3
reply_idintThe reply id.
encryptedboolHow it now stands.
changedboolWhether anything moved. False means it already stood that way.
Errors 2
not_found404No such ticket or reply.
insufficient_scope403The key lacks the required scope.
Request
curl -X PUT 'https://panel.example.com/api/v1/admin/tickets/402/messages/846/encryption' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"encrypted":true}'
const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/messages/${replyId}/encryption`, {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ encrypted: true }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages/' . $replyId . '/encryption');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'PUT',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['encrypted' => true]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Encryption changes only how it is STORED: staff read the text either way.
$r = Api::Tickets()->SetTicketMessageEncryption([
    'id' => $id, 'reply_id' => $replyId, 'encrypted' => true,
])['data'];

Changing the Visibility

put/api/v1/admin/tickets/{id}/messages/{reply_id}/visibility
Tickets/SetTicketMessageVisibility admin

Hides a staff reply from the client, or shows it again.

Body 1
hiddenboolreqHides it, or shows it. Only staff replies can be hidden.
Response fields data — 3
reply_idintThe reply id.
hiddenboolHow it now stands.
changedboolWhether anything moved.
Errors 3
hide_only_staff422A client reply cannot be hidden.
not_found404No such ticket or reply.
insufficient_scope403The key lacks the required scope.
Request
curl -X PUT 'https://panel.example.com/api/v1/admin/tickets/402/messages/848/visibility' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"hidden":true}'
const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/messages/${replyId}/visibility`, {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ hidden: true }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages/' . $replyId . '/visibility');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'PUT',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['hidden' => true]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Hiding works after the fact, yet it cannot pull back the mail ALREADY SENT.
Api::Tickets()->SetTicketMessageVisibility([
    'id' => $id, 'reply_id' => $replyId, 'hidden' => true,
]);

Downloading an Attachment

get/api/v1/admin/tickets/{id}/attachments/{att_id}
Tickets/GetTicketAttachment admin body arrives as text

Returns an attachment's details together with its content.

Response fields data — 6
idintThe attachment id.
namestringThe name it shows under.
file_namestringThe name it is stored under.
file_sizeintThe file size.
mimestringThe file type.
content_base64stringThe file content. It arrives turned into text, because the response is a document.
Errors 2
not_found404No such attachment, or its file is missing.
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/tickets/402/attachments/202' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/attachments/${attId}`, {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const { data } = await res.json();

const bytes = Uint8Array.from(atob(data.content_base64), (c) => c.charCodeAt(0));
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/attachments/' . $attId);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Turning the content into text makes the body roughly A THIRD larger than the file.
$att = Api::Tickets()->GetTicketAttachment(['id' => $id, 'att_id' => $attId])['data'];
file_put_contents($path, base64_decode($att['content_base64']));

Deleting an Attachment

delete/api/v1/admin/tickets/{id}/attachments/{att_id}
Tickets/DeleteTicketAttachment admin

Removes an attachment from its record and from disk.

Response fields data — 2
deletedboolWhether the delete ran.
attachment_idintThe id of the attachment removed.
Errors 2
not_found404No such ticket or attachment.
insufficient_scope403The key lacks the required scope.
Request
curl -X DELETE 'https://panel.example.com/api/v1/admin/tickets/402/attachments/202' \
  -H "Authorization: Bearer $API_KEY"
const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/attachments/${attId}`, {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/attachments/' . $attId);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'DELETE',
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// This endpoint removes REPLY attachments; note attachments have endpoints of their own.
Api::Tickets()->DeleteTicketAttachment(['id' => $id, 'att_id' => $attId]);

Pitfalls

There is no page number

This list is walked with a cursor: you ask for the next page with the oldest reply id you hold. Sending a page number does nothing and returns the same first page forever. The meta block hands you both the oldest and the newest id, so keep them both.

The same message reads blank in one place and open here

The last-reply preview in a ticket list arrives blank for encrypted messages, while this endpoint hands the text back decoded. The two do not contradict: a list sweeps many tickets and keeps ciphertext out of that surface. Encryption is about storage rather than about keeping staff out.

Hidden replies come back in this list too

Staff-only replies never reach the client, yet this endpoint returns them alongside the rest. An integration that shows the conversation to a customer as it comes leaks the internal notes with it. Filter the hidden ones out yourself on any customer-facing surface.

Writing a reply sends the mail at once

Adding a reply moves the ticket to replied and e-mails the client. Fixing the text afterwards does not pull that mail back, and neither does hiding it. When you only mean to record something, add the reply hidden: the status stays put and no mail goes out.

Downloading an attachment inflates the body

Because the response is a document, the file content arrives turned into text and the body runs about a third larger than the file. A hundred-megabyte attachment means a hundred-and-thirty-megabyte response, and a client that loads it whole stops there. Stream large attachments, and leave them alone unless you need them.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.