Internal Notes and Secrets

7 views Markdown

The nine endpoints for staff-only notes and the secrets kept on a ticket.

Overview

These nine endpoints deal with the side of a ticket the client never sees. Six manage staff-only notes and their files. Three hold labelled secrets, which is where things shared during a conversation, such as server access, belong.

Both live on the ticket's own row, encrypted. Neither has a table of its own, so neither has paging or filtering: the list always arrives whole.

Adding a note raises no notice and leaves the ticket status alone. When you want text that stays out of the client's view yet belongs to the conversation itself, what you want is a hidden reply rather than a note.

Reference

Listing the Notes

get/api/v1/admin/tickets/{id}/notes
Tickets/GetTicketNotes admin

Returns the notes staff left on a ticket.

Response fields data[] — 8
idstringThe note id. A sixteen-character string rather than a position.
author_idintWho wrote it.
messagestringThe note text.
pinnedboolWhether it is pinned. Pinned notes sit at the top in the panel.
aiboolWhether an assistant wrote it. The panel shows it under a different author.
legacyboolWhether it came from the older format.
created_atstring | nullWhen it was written.
attachmentsarrayThe attachments: id, shown name, stored name and size.
Errors 2
not_found404No such ticket.
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/tickets/402/notes' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/notes`, {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/notes');
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 paging: every note comes at once, decoded from the ticket's own row.
$notes = Api::Tickets()->GetTicketNotes(['id' => $id])['data'];

Adding a Note

post/api/v1/admin/tickets/{id}/notes
Tickets/AddTicketNote admin

Leaves a staff-only note on a ticket.

Body 4
messagestringreqThe note text.
pinnedboolPins the note.
aiboolMarks the note as written by an assistant.
attachmentsstring | object | arrayFiles to attach.
Response fields 201 — data
dataobjectThe note added. Same shape as a list item.
Errors 4
message_required422The note text is empty.
blocked_by_gate422A hook refused the note.
not_found404No such ticket.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/tickets/402/notes' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"message":"Muhasebeye devredildi.","pinned":true}'
const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/notes`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    message: 'Escalated to the billing team.',
    pinned: true,
  }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/notes');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'message' => 'Escalated to the billing team.',
        'pinned'  => true,
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// A note NEVER REACHES THE CLIENT and raises no notice; only staff see it.
$note = Api::Tickets()->AddTicketNote([
    'id'      => $id,
    'message' => 'Escalated to the billing team.',
])['data'];

Updating a Note

patch/api/v1/admin/tickets/{id}/notes/{note_id}
Tickets/UpdateTicketNote admin

Changes a note's text or whether it is pinned.

Body 2
messagestringThe new text.
pinnedboolPins it, or unpins it.
Response fields data
dataobjectThe note as it now reads. Same shape as a list item.
Errors 5
no_changes422No field was given. An empty body does not pass quietly.
message_required422The note text is empty.
update_failed422The update could not be written.
not_found404No such ticket or note.
insufficient_scope403The key lacks the required scope.
Request
curl -X PATCH 'https://panel.example.com/api/v1/admin/tickets/402/notes/1d9e35094420c723' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"pinned":false}'
const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/notes/${noteId}`, {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ pinned: false }),
});

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

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// An empty body is an ERROR rather than a no-op; send at least one field.
Api::Tickets()->UpdateTicketNote([
    'id' => $id, 'note_id' => $noteId, 'pinned' => false,
]);

Deleting a Note

delete/api/v1/admin/tickets/{id}/notes/{note_id}
Tickets/DeleteTicketNote admin

Removes a note along with its files.

Response fields data — 2
deletedboolWhether the delete ran.
note_idstringThe id of the note removed.
Errors 3
delete_failed422The delete could not be written.
not_found404No such ticket or note.
insufficient_scope403The key lacks the required scope.
Request
curl -X DELETE 'https://panel.example.com/api/v1/admin/tickets/402/notes/1d9e35094420c723' \
  -H "Authorization: Bearer $API_KEY"
const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/notes/${noteId}`, {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/notes/' . $noteId);
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 note's attachments go too: dropped from the record and removed from disk.
Api::Tickets()->DeleteTicketNote(['id' => $id, 'note_id' => $noteId]);

Downloading a Note Attachment

get/api/v1/admin/tickets/{id}/notes/{note_id}/attachments/{att_id}
Tickets/GetTicketNoteAttachment admin

Returns a file attached to a note, with its content.

Response fields data — 6
idstringThe 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.
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/notes/1d9e35094420c723/attachments/71c918b7ff525444' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch(
  `https://panel.example.com/api/v1/admin/tickets/${id}/notes/${noteId}/attachments/${attId}`,
  { headers: { Authorization: `Bearer ${apiKey}` } },
);
const { data } = await res.json();
$url = 'https://panel.example.com/api/v1/admin/tickets/' . $id . '/notes/' . $noteId . '/attachments/' . $attId;

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Note attachments live on the ticket row and are NOT fetched from the reply endpoint.
$att = Api::Tickets()->GetTicketNoteAttachment([
    'id' => $id, 'note_id' => $noteId, 'att_id' => $attId,
])['data'];

Deleting a Note Attachment

delete/api/v1/admin/tickets/{id}/notes/{note_id}/attachments/{att_id}
Tickets/DeleteTicketNoteAttachment admin

Removes a note attachment from its record and from disk.

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

const body = await res.json();
$url = 'https://panel.example.com/api/v1/admin/tickets/' . $id . '/notes/' . $noteId . '/attachments/' . $attId;

$ch = curl_init($url);
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 file goes while the note stays; deleting the note takes its files along.
Api::Tickets()->DeleteTicketNoteAttachment([
    'id' => $id, 'note_id' => $noteId, 'att_id' => $attId,
]);

Listing the Secrets

get/api/v1/admin/tickets/{id}/private-data
Tickets/GetTicketPrivateData admin content comes back in the clear

Returns the labelled secrets kept on a ticket.

Response fields data[] — 5
idstringThe item id.
labelstringWhat the item is.
contentstringThe item itself. Stored encrypted and handed back decoded.
reply_idintThe reply it belongs to. Zero means it belongs to the ticket as a whole.
created_atstring | nullWhen it was added.
Errors 2
not_found404No such ticket.
insufficient_scope403The key lacks the required scope.
Request
curl 'https://panel.example.com/api/v1/admin/tickets/402/private-data' \
  -H "Authorization: Bearer $API_KEY"
const res  = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/private-data`, {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/private-data');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// The content comes back IN THE CLEAR: a client that logs this call logs the password too.
$items = Api::Tickets()->GetTicketPrivateData(['id' => $id])['data'];

Adding a Secret

post/api/v1/admin/tickets/{id}/private-data
Tickets/AddTicketPrivateData admin

Adds a labelled secret to a ticket.

Body 3
labelstringreqWhat the item is.
contentstringreqThe item itself. It gets encrypted on the way in.
reply_idintTies it to a particular reply.
Response fields 201 — data
dataobjectThe item added. Same shape as a list item.
Errors 3
label_content_required422The label or the content is empty.
not_found404No such ticket.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/tickets/402/private-data' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"label":"Sunucu erisimi","content":"host: 203.0.113.10"}'
const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/private-data`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    label: 'Server SSH',
    content: 'host: 203.0.113.10\nuser: admin',
  }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/private-data');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'label'   => 'Server SSH',
        'content' => "host: 203.0.113.10\nuser: admin",
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// Give a reply id to tie it there; zero leaves it on the ticket as a whole.
Api::Tickets()->AddTicketPrivateData([
    'id'       => $id,
    'label'    => 'Server SSH',
    'content'  => $credentials,
    'reply_id' => $replyId,
]);

Deleting a Secret

delete/api/v1/admin/tickets/{id}/private-data/{item_id}
Tickets/DeleteTicketPrivateData admin

Removes one secret from a ticket.

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

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/private-data/' . $itemId);
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);
// REMOVING credentials once a ticket closes is a good habit; the record otherwise stays.
Api::Tickets()->DeleteTicketPrivateData(['id' => $id, 'item_id' => $itemId]);

Pitfalls

Secrets come back as plain text

These items sit encrypted in the database, yet the read endpoint hands the content back decoded. A key carrying this scope reads whatever server passwords are stored there. A client that logs its requests and responses logs the password with them, so keep the key narrow and keep this call's body out of your logs.

All of it sits in one encrypted block

Notes and secrets are not separate rows but one encrypted field on the ticket row. Every write saves that whole block again. When two requests add a note at the same moment the second can write over the first, so avoid writing to one ticket in parallel during batch work.

The ids are not positions

Notes and their attachments are addressed by a string, not by where they sit in the list. The older panel used the position, and adding or removing a note shifted it. Keep a position on your side and one day you will update a different note, so store the id that comes back.

An empty update is an error

Send no field at all on a note update and the call is refused. The ticket update does not behave that way, where an unchanged field passes quietly. A sync script that forwards whatever it holds hits an error here when nothing changed. Check you really have a field before you send.

A merge does not carry notes and secrets across

Merging tickets moves replies and attachments to the main ticket and then deletes the other ticket rows. Notes and secrets live on those rows, so they go with them. The same holds when you delete a ticket. If the only copy of a server password sits there, read it and write it onto the main ticket before you merge.

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.