Tickets
Create, list, update, and manage support tickets programmatically. Tickets are the core of the Helmdesk helpdesk — each belongs to a project and is linked to a customer.
Which project?
Every ticket belongs to one project. A key limited to one project implies it — send nothing extra. A key that reaches several projects (the kind you give a coding agent) names the project on each request with the Helmdesk-Project header (slug or id) or ?project=; without it the API answers 400 project.required with the projects it could have meant. Every object in a response carries projectId and projectSlug, so you can always tell what you are looking at.
curl https://helmdesk.dev/api/v1/tickets \
-H "Authorization: Bearer sk_live_..." \
-H "Helmdesk-Project: ledgerly"// Pin the project once — or set HELMDESK_PROJECT and omit it.
const helmdesk = new HelmdeskClient({ apiKey, project: 'ledgerly' })
await helmdesk.tickets.list()See Authentication for how keys and projects fit together.
/api/v1/ticketsCreate a support ticket. A customer record is automatically created or matched by email.
Request
const ticket = await helmdesk.tickets.create({
subject: 'Cannot login',
body: 'I keep getting a 403 error when...',
customerEmail: 'user@example.com',
customerName: 'Jane Smith',
priority: 'high',
})/api/v1/ticketsList tickets with pagination. Filter by status, priority, category, assignee, exact ticket number, awaitingReply, or search by subject/email. The assignee filter accepts a user id, a comma-separated list, or 'unassigned'. Every row carries lastSenderType and lastMessageAt — the latest message on the thread, computed in the same query.
Request
const { tickets, total } = await helmdesk.tickets.list({
status: 'open',
priority: 'high',
assignee: 'unassigned',
category: 'billing',
number: 42, // exact "#42" lookup
awaitingReply: true, // only tickets waiting on you
page: 1,
limit: 25,
})
// tickets[0].lastSenderType -> 'customer' | 'staff' | null
// tickets[0].lastMessageAt -> ISO timestamp | nullTickets awaiting a reply
The question a support tool gets asked most is "what needs me?". Rather than reading every thread, pass awaitingReply=true: it returns tickets in status new, open, or pending whose latest message came from the customer — the same definition the Agents Activity inbox uses. Each row also says who wrote last (lastSenderType) and when (lastMessageAt), so a list is enough to triage.
curl "https://helmdesk.dev/api/v1/tickets?awaitingReply=true&limit=50" \
-H "Authorization: Bearer sk_live_..." \
-H "Helmdesk-Project: ledgerly"const { tickets } = await helmdesk.tickets.list({ awaitingReply: true })
for (const t of tickets) {
console.log('#' + t.number + ' ' + t.subject + ' — customer wrote at ' + t.lastMessageAt)
}/api/v1/tickets/:idGet full ticket details including messages, tags, and customer info.
Request
const ticket = await helmdesk.tickets.get('ticket-uuid')
// ticket.messages, ticket.tags, ticket.customer/api/v1/tickets/:idUpdate ticket status, priority, category, or assignee. The assignee must be an active member of your account — discover valid ids with the members endpoint below.
Request
await helmdesk.tickets.update('ticket-uuid', {
status: 'resolved',
priority: 'low',
assignedTo: 'user-uuid', // or null to unassign
})/api/v1/tickets/:id/messagesReply to a ticket as staff. With visibility 'public' (the default) the reply is appended to the thread, emailed to the customer, and a 'new' ticket transitions to 'open'. With visibility 'internal' it is a staff-only note — see below.
Request
await helmdesk.tickets.addMessage('ticket-uuid', {
body: 'Thanks for reaching out! Here\'s how to fix it...',
})Internal notes
A message with visibility: "internal" is for the team only: it sits on the thread and comes back from GET /tickets/:id marked internal, but it is never shown on the customer portal, never emailed, and it does not move the ticket status. Use it for context and handoffs — what you found, what you tried, why the ticket is waiting.
curl -X POST https://helmdesk.dev/api/v1/tickets/TICKET_ID/messages \
-H "Authorization: Bearer sk_live_..." \
-H "Helmdesk-Project: ledgerly" \
-H "Content-Type: application/json" \
-d '{ "body": "Escalated to eng — ETA Friday.", "visibility": "internal" }'await helmdesk.tickets.addNote('ticket-uuid', 'Escalated to eng — ETA Friday.')
// The long form, if you are already building the input:
await helmdesk.tickets.addMessage('ticket-uuid', {
body: 'Escalated to eng — ETA Friday.',
visibility: 'internal',
})Only staff can write notes
Bulk update
Apply one change — status, priority, and/or assignee — to up to 100 tickets in a call, the same operation as the dashboard bulk action bar. Each ticket is updated individually, so a resolved ticket still fires its ticket.resolved webhook and emails the customer the satisfaction survey (never in sandbox); staff notification emails are deliberately not sent per ticket. Ids outside the project or environment are counted as skipped and never touched; a ticket that cannot take the patch — say an assignee who is not an active member — lands in failures instead of failing the batch.
/api/v1/tickets/bulktickets:writeBody: ticketIds (1–100 UUIDs) plus at least one of status, priority, assignedTo (null unassigns). Category is deliberately not a bulk field. Responds with updated, failed, skipped, the ids that changed, and one failures entry per ticket that could not be updated.
Request
const result = await helmdesk.tickets.bulkUpdate({
ticketIds: ['ticket-1', 'ticket-2', 'ticket-3'],
status: 'closed',
assignedTo: null, // unassign; omit to leave assignment alone
})
// { updated: 3, failed: 0, skipped: 0, ids: [...], failures: [] }Response
{
"projectId": "3f2a1c44-7d21-4a5e-9f0b-2c8d6e1a9b77",
"projectSlug": "ledgerly",
"updated": 2,
"failed": 1,
"skipped": 0,
"ids": ["9b1c7f2e-…", "c4e2a8b1-…"],
"failures": [{ "id": "7d3e9a10-…", "message": "Validation failed" }]
}curl -X POST https://helmdesk.dev/api/v1/tickets/bulk \
-H "Authorization: Bearer sk_live_..." \
-H "Helmdesk-Project: ledgerly" \
-H "Content-Type: application/json" \
-d '{ "ticketIds": ["9b1c7f2e-…", "c4e2a8b1-…"], "status": "closed", "assignedTo": null }'/api/v1/membersList the active members of your account (id, name, email, role) — the valid assignedTo values for ticket assignment. Requires the tickets:read scope.
Request
const { members } = await helmdesk.team.list()
// members[0].userId → use as assignedTo/api/v1/tickets/:id/tagsAdd a tag (label) to a ticket for triage or categorization. Idempotent — adding an existing tag is a no-op. Returns the ticket's full tag list.
Request
const { tags } = await helmdesk.tickets.addTag('ticket-uuid', 'needs-engineering')/api/v1/tickets/:id/tagsRemove a tag from a ticket. Returns the ticket's remaining tags.
Request
const { tags } = await helmdesk.tickets.removeTag('ticket-uuid', 'needs-engineering')Deleting a ticket
Everything the API can create it can delete, through the same code path the dashboard uses. Deleting a ticket removes its messages, tags, and attachments (the storage objects are removed and the bytes released from your quota). The customer is not emailed. There is no undo — to finish a ticket without losing its history, set its status to closed instead.
/api/v1/tickets/:idtickets:writePermanently delete a ticket. Scoped to the project and the request's environment — a live id given to a sandbox request is a 404, never a cross-plane delete.
Request
// SDK
const result = await helmdesk.tickets.delete('ticket-uuid')
// curl
curl -X DELETE https://helmdesk.dev/api/v1/tickets/ticket-uuid \
-H "Authorization: Bearer sk_live_..." \
-H "Helmdesk-Project: abc-salon"Response
{
"deleted": true,
"id": "3b8e1c2d-9f4a-4e6b-8c7d-1a2b3c4d5e6f",
"attachmentsRemoved": 2,
"projectId": "7f3a9c2e-4b1d-4e8f-a6c5-2d9b8e7f1a30",
"projectSlug": "abc-salon"
}Ticket statuses
newopenpendingresolvedclosedPriority levels
lowmediumhighurgent