Developers

How to Build a Support Ticket API Integration (with Code)

A practical guide to integrating a support ticket API: create tickets from your app, add replies and internal notes, sync tickets, receive webhooks with signature verification and handle rate limits.

TickOS Team · Oct 17, 2026 · 6 min read

Email and contact forms cover most support requests, but sooner or later your product needs to talk to your help desk directly: a "Contact support" button inside the app, an automatic ticket when a payment fails, or a "My tickets" page in the customer's account.

That's what a support ticket API is for. This guide walks through a complete integration, with working code: creating tickets from your backend, adding replies and internal notes, syncing tickets, receiving webhooks securely and handling errors and rate limits. The examples use the TickOS API, but the patterns apply to any help desk.

What you can build with a ticket API

  • In-app support form. The customer is already logged in, so you know who they are, their plan and what page they were on. No need to ask for their email again.
  • Automatic tickets. Create a ticket when a payment fails, an import breaks or a customer hits a critical error, before they even write to you.
  • "My tickets" in your app. Show customers the status of their requests without leaving your product.
  • Internal tools. Add diagnostic data to a ticket as a private note, or open tickets from your admin panel.
  • Sync with other systems. React to new tickets and replies from your CRM, Slack, data warehouse or an AI agent.

How the integration fits together

The architecture is the same for almost every use case:

  1. Your frontend sends the request to your backend (never directly to the help desk).
  2. Your backend adds what it knows about the user and calls the ticket API with a secret key.
  3. The help desk creates the ticket, notifies your team and emails the customer when your team replies.
  4. Webhooks tell your backend when something changes, like a new reply.

Never expose your API key

API keys give access to your whole workspace. Keep them in an environment variable on the server and never send them to the browser or commit them to source control.

Step 1: Create an API key

In TickOS, go to Settings → Developers and create an API key. Keys start with sk_ and have one of three permission levels: read, write or admin. Creating tickets and messages needs write. Use a separate key per integration, so you can revoke one without breaking the others.

Store it as an environment variable:

terminal
Bash
01TICKOS_API_KEY=sk_your_api_key
UTF-8 · LF · Bashtickos.dev

Every request authenticates with a Bearer token:

request.http
HTTP
01Authorization: Bearer sk_your_api_key
UTF-8 · LF · HTTPtickos.dev

Step 2: Create a ticket from your backend

A ticket needs a subject, a message and a customer. If the customer's email doesn't exist yet, TickOS creates the customer automatically. Use metadata to attach anything your team will need later, like the user ID, plan or the page they were on.

example.js
JavaScript
01// lib/support.js — runs on your server only
02const TICKOS_API = 'https://api.tickos.dev/v1'
03 
04export async function createSupportTicket({ email, name, subject, message, metadata }) {
05 const res = await fetch(`${TICKOS_API}/tickets`, {
06 method: 'POST',
07 headers: {
08 Authorization: `Bearer ${process.env.TICKOS_API_KEY}`,
09 'Content-Type': 'application/json',
10 },
11 body: JSON.stringify({
12 subject,
13 message,
14 customer_email: email,
15 customer_name: name,
16 metadata,
17 }),
18 })
19 
20 if (!res.ok) {
21 throw new Error(`TickOS error ${res.status}: ${await res.text()}`)
22 }
23 
24 const { ticket } = await res.json()
25 return ticket // ticket.id (tkv_...) and ticket.client_id (e.g. SUP-000142)
26}
UTF-8 · LF · JavaScripttickos.dev

Then expose a small route that your in-app form calls. Here's a Next.js route handler; the same idea works in Express, Rails, Django or Laravel:

example.js
JavaScript
01// app/api/support/route.js
02import { createSupportTicket } from '@/lib/support'
03import { getCurrentUser } from '@/lib/auth' // your own auth helper
04 
05export async function POST(request) {
06 const user = await getCurrentUser(request)
07 if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 })
08 
09 const { subject, message, page } = await request.json()
10 if (!subject || !message) {
11 return Response.json({ error: 'Subject and message are required' }, { status: 400 })
12 }
13 
14 const ticket = await createSupportTicket({
15 email: user.email,
16 name: user.name,
17 subject,
18 message,
19 metadata: { user_id: user.id, plan: user.plan, page },
20 })
21 
22 return Response.json({ ticketId: ticket.client_id }, { status: 201 })
23}
UTF-8 · LF · JavaScripttickos.dev

The API responds with 201 and the new ticket. Show the client_id to the customer ("Your request SUP-000142 was received"), and store the ticket id if you plan to update it later.

A few optional fields are worth knowing:

FieldUse it to
priorityMark urgent requests: low, normal, medium, high or urgent
inbox_id or inbox_emailRoute the ticket to a specific inbox, like billing or technical support
assigned_toAssign it to a teammate by email
attachmentsAttach files as base64 (fileName, fileData, contentType)

Step 3: Add replies and internal notes

Messages are added to an existing ticket with POST /v1/tickets/{id}/messages. The direction field decides what kind of message it is:

directionWhat it does
inbound (default)Adds a message from the customer, for example a follow-up sent from your app
outboundSends an email reply to the customer from the inbox address
internalAdds a private note that only your team sees

Internal notes are perfect for diagnostic data your team needs but the customer shouldn't see:

example.js
JavaScript
01export async function addInternalNote(ticketId, text) {
02 const res = await fetch(`${TICKOS_API}/tickets/${ticketId}/messages`, {
03 method: 'POST',
04 headers: {
05 Authorization: `Bearer ${process.env.TICKOS_API_KEY}`,
06 'Content-Type': 'application/json',
07 },
08 body: JSON.stringify({ body_text: text, direction: 'internal' }),
09 })
10 if (!res.ok) throw new Error(`TickOS error ${res.status}`)
11 return res.json()
12}
13 
14// Example: attach the last error your app logged for this user
15await addInternalNote(ticket.id, `Last error: ${lastError.message}\nBrowser: ${userAgent}`)
UTF-8 · LF · JavaScripttickos.dev

direction defaults to inbound

If you want to email the customer, send direction: "outbound" explicitly. Without it, the message is stored as if the customer had written it, and no email is sent.

Step 4: Show a customer their tickets

To build a "My tickets" page, list tickets filtered by the customer and show their status. The search parameter matches subject, ticket ID, customer email and name, so filter the results by exact email on your side:

example.js
JavaScript
01export async function listCustomerTickets(email) {
02 const params = new URLSearchParams({ search: email, limit: '20' })
03 const res = await fetch(`${TICKOS_API}/tickets?${params}`, {
04 headers: { Authorization: `Bearer ${process.env.TICKOS_API_KEY}` },
05 })
06 if (!res.ok) throw new Error(`TickOS error ${res.status}`)
07 
08 const { data } = await res.json()
09 return data
10 .filter((t) => t.customer?.email?.toLowerCase() === email.toLowerCase())
11 .map((t) => ({ id: t.client_id, subject: t.subject, status: t.status, updatedAt: t.updated_at }))
12}
UTF-8 · LF · JavaScripttickos.dev

Ticket statuses move through open, pending (waiting on the customer), review (the customer replied), resolved and closed. A customer reply automatically reopens resolved and closed tickets.

Step 5: Receive webhooks

Polling is fine for a "My tickets" page, but for anything real-time, use webhooks. Add a webhook URL to an inbox (in the inbox settings or with PATCH /v1/inboxes/{id}) and choose the events you need:

EventFired when
ticket.createdA new ticket arrives from any channel: email, form, help center or API
ticket.updatedA ticket is edited
ticket.deletedA ticket is deleted
message.createdA message is added: a customer reply, an agent reply or a message from the API
inbox.updated / inbox.deletedThe inbox settings change or the inbox is deleted

Each delivery is a POST with a JSON body like this:

response.json
JSON
01{
02 "event": "ticket.created",
03 "timestamp": "2026-10-13T15:04:05.000Z",
04 "data": {
05 "inbox": { "id": "inb_...", "name": "Support" },
06 "ticket": { "id": "tkv_...", "client_id": "SUP-000123", "subject": "Cannot access my account", "status": "open" },
07 "customer": { "id": "cus_...", "email": "jane@example.com", "name": "Jane Doe" }
08 }
09}
UTF-8 · LF · JSONtickos.dev

Verify the signature

Every delivery from a webhook with a secret includes an X-Tickos-Signature header with a timestamp and an HMAC-SHA256 signature. Always verify it before trusting the payload, using the raw request body and a constant-time comparison. Rejecting old timestamps protects you from replayed requests:

example.js
JavaScript
01import crypto from 'node:crypto'
02import express from 'express'
03 
04const FIVE_MINUTES = 5 * 60
05 
06function verifyTickosSignature(rawBody, header, secret) {
07 const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
08 const timestamp = Number(parts.t)
09 const signature = parts.v1
10 if (!timestamp || !signature) return false
11 if (Math.abs(Date.now() / 1000 - timestamp) > FIVE_MINUTES) return false
12 
13 const expected = crypto
14 .createHmac('sha256', secret)
15 .update(`${timestamp}.${rawBody}`)
16 .digest('hex')
17 
18 return (
19 signature.length === expected.length &&
20 crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
21 )
22}
23 
24const app = express()
25 
26// express.raw keeps the body exactly as received, which the signature needs
27app.post('/webhooks/tickos', express.raw({ type: 'application/json' }), (req, res) => {
28 const rawBody = req.body.toString('utf8')
29 const header = req.get('X-Tickos-Signature') || ''
30 
31 if (!verifyTickosSignature(rawBody, header, process.env.TICKOS_WEBHOOK_SECRET)) {
32 return res.sendStatus(401)
33 }
34 
35 // Acknowledge fast, then do the work
36 res.sendStatus(200)
37 
38 const { event, data } = JSON.parse(rawBody)
39 if (event === 'message.created') {
40 // e.g. notify the user in your app, update your CRM, post to Slack...
41 }
42})
UTF-8 · LF · JavaScripttickos.dev

A few rules for reliable webhooks:

  • Respond with a 2xx quickly. Deliveries time out after 10 seconds, so move slow work to a background job.
  • Expect events out of order. Use the timestamp and the resource's updated_at to decide what's newest.
  • Make handlers idempotent. Processing the same event twice shouldn't create duplicates on your side.
  • Reconcile after downtime. TickOS attempts each delivery once and doesn't retry, so if your endpoint was down, list what changed with GET /v1/tickets?updated_after=....

Step 6: Handle errors and rate limits

Plan for these responses from day one:

StatusMeaningWhat to do
400Missing or invalid fieldsFix the request; show a clear message to the user
401Missing, invalid or revoked API keyCheck the key in your environment
403Key permission too low, or the customer is blockedUse a write key; don't retry
404Ticket or inbox not found in your workspaceCheck the ID you stored
429Rate limit or plan limit reachedWait for Retry-After seconds, then retry

Rate limits are per API key and per minute: 120 on Free, 600 on Starter, 1,500 on Pro and 6,000 on Enterprise. A small helper that respects Retry-After covers most cases:

example.js
JavaScript
01export async function tickosFetch(path, options = {}, retries = 2) {
02 const res = await fetch(`${TICKOS_API}${path}`, {
03 ...options,
04 headers: {
05 Authorization: `Bearer ${process.env.TICKOS_API_KEY}`,
06 'Content-Type': 'application/json',
07 ...options.headers,
08 },
09 })
10 
11 if (res.status === 429 && retries > 0) {
12 const wait = Number(res.headers.get('Retry-After') || 1)
13 await new Promise((r) => setTimeout(r, wait * 1000))
14 return tickosFetch(path, options, retries - 1)
15 }
16 
17 if (!res.ok) throw new Error(`TickOS error ${res.status}: ${await res.text()}`)
18 return res.json()
19}
UTF-8 · LF · JavaScripttickos.dev

Never retry a ticket creation blindly after a network timeout: the first request may have succeeded. Store the ticket ID as soon as you get it, and check before creating a second one.

The same flow in Python

If your backend is in Python, the create call looks like this:

example.py
Python
01import os
02import requests
03 
04def create_support_ticket(email, name, subject, message, metadata=None):
05 res = requests.post(
06 "https://api.tickos.dev/v1/tickets",
07 headers={"Authorization": f"Bearer {os.environ['TICKOS_API_KEY']}"},
08 json={
09 "subject": subject,
10 "message": message,
11 "customer_email": email,
12 "customer_name": name,
13 "metadata": metadata or {},
14 },
15 timeout=10,
16 )
17 res.raise_for_status()
18 return res.json()["ticket"]
UTF-8 · LF · Pythontickos.dev

Launch checklist

  • The API key lives in an environment variable on the server, with write permission only.
  • Your form validates input and shows a friendly error if ticket creation fails.
  • Tickets include useful metadata (user ID, plan, page) for your team.
  • Webhook signatures are verified with the raw body, and old timestamps are rejected.
  • Webhook handlers respond fast and are idempotent.
  • You handle 429 with Retry-After and never blindly retry ticket creation.
  • You've tested the full loop: create a ticket from the app, reply from the help desk, receive the message.created webhook.

Going further

The full reference, including every field, filter and error, is in the TickOS API documentation. A good next read is the tickets reference and the webhooks guide. If you also send transactional email from your app, our comparison of Resend alternatives shows how to do it from the same platform. And if you're still choosing how customers reach you, start with how to turn emails into support tickets.

Frequently asked questions

  • A support ticket API lets your own software create, read and update tickets in a help desk over HTTP. Typical uses are a contact form inside your app, automatic tickets for failed payments or errors, showing a customer's tickets in their account, and syncing tickets with other tools.

Keep reading

NEW TICKET — from your team

Your support, finally on rails.

Create your workspace in two minutes. The free plan includes unlimited tickets, unlimited teammates and your own help center.

PLAN

Free

TICKETS

Unlimited

TEAMMATES

Unlimited

status: open