Integrating into Your App
Add a complete support ticketing system to your app in minutes.
Quick Start
npm install @dispatchtickets/sdkimport { DispatchTickets, DispatchPortal } from '@dispatchtickets/sdk';
// 1. Backend: Generate a portal token for your user
const client = new DispatchTickets({ apiKey: 'sk_live_xxx' });
const { token } = await client.brands.generatePortalToken('br_xxx', {
email: user.email,
name: user.name,
});
// 2. Frontend: Use the token to access tickets
const portal = new DispatchPortal({ token });
const { data: tickets } = await portal.tickets.list();Overview
Dispatch Tickets provides two APIs:
| API | Auth | Purpose |
|---|---|---|
| Admin API | sk_live_xxx | Manage tickets, customers, brands. Used by your backend. |
| Portal API | Portal Token | End-users view/create their own tickets. Scoped to one customer. |
Integration Patterns
Choose based on your needs:
| Pattern | Complexity | Best For |
|---|---|---|
| Backend Proxy | Medium | Maximum security, SSR apps, complex auth |
| Direct Frontend | Simple | SPAs, quick setup, client-side apps |
Pattern 1: Backend Proxy (Recommended)
Your backend generates tokens and proxies all portal requests. Users never see the portal token.
Setup
import { DispatchTickets, DispatchPortal } from '@dispatchtickets/sdk';
const admin = new DispatchTickets({
apiKey: process.env.DISPATCH_API_KEY!,
});
const brandId = process.env.DISPATCH_BRAND_ID!;
// Cache portal clients (tokens expire after 1 hour)
const portalCache = new Map<string, { portal: DispatchPortal; expiresAt: number }>();
export async function getPortalClient(email: string, name?: string): Promise<DispatchPortal> {
const cached = portalCache.get(email);
const now = Date.now();
if (cached && cached.expiresAt > now) {
return cached.portal;
}
const { token } = await admin.brands.generatePortalToken(brandId, { email, name });
const portal = new DispatchPortal({ token });
// Cache for 50 minutes (tokens last 1 hour)
portalCache.set(email, {
portal,
expiresAt: now + 50 * 60 * 1000,
});
return portal;
}API Routes
import { getPortalClient } from './lib/dispatch';
// List tickets
app.get('/api/tickets', async (req, res) => {
const portal = await getPortalClient(req.user.email, req.user.name);
const tickets = await portal.tickets.list(req.query);
res.json(tickets);
});
// Get ticket
app.get('/api/tickets/:id', async (req, res) => {
const portal = await getPortalClient(req.user.email, req.user.name);
const ticket = await portal.tickets.get(req.params.id);
res.json(ticket);
});
// Create ticket
app.post('/api/tickets', async (req, res) => {
const portal = await getPortalClient(req.user.email, req.user.name);
const ticket = await portal.tickets.create(req.body, {
idempotencyKey: req.headers['x-idempotency-key'],
});
res.json(ticket);
});
// Add comment
app.post('/api/tickets/:id/comments', async (req, res) => {
const portal = await getPortalClient(req.user.email, req.user.name);
const comment = await portal.tickets.addComment(req.params.id, req.body.body, {
idempotencyKey: req.headers['x-idempotency-key'],
});
res.json(comment);
});Frontend
// Your frontend calls your backend, not Dispatch directly
const tickets = await fetch('/api/tickets').then(r => r.json());
const newTicket = await fetch('/api/tickets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({ title: 'Help!', body: 'Details...' }),
}).then(r => r.json());Pattern 2: Direct Frontend
Your backend generates a token and passes it to the frontend. The frontend calls the Portal API directly.
Prerequisites
Configure allowed origins for your brand:
// Allow your frontend domain
await admin.brands.update(brandId, {
portalOrigins: ['https://yourapp.com', 'https://www.yourapp.com'],
});Backend: Token Endpoint
app.get('/api/support/token', async (req, res) => {
const { token, expiresAt } = await admin.brands.generatePortalToken(brandId, {
email: req.user.email,
name: req.user.name,
});
res.json({ token, expiresAt });
});Frontend: Direct API Calls
import { DispatchPortal } from '@dispatchtickets/sdk';
// Get token from your backend
const { token } = await fetch('/api/support/token').then(r => r.json());
// Use it directly in the browser
const portal = new DispatchPortal({ token });
const { data: tickets } = await portal.tickets.list();
const ticket = await portal.tickets.create({ title: 'Help!', body: 'Details...' });
await portal.tickets.addComment(ticket.id, 'More info...');React Hook Example
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { DispatchPortal } from '@dispatchtickets/sdk';
// Get or create portal client
async function getPortal(): Promise<DispatchPortal> {
const cached = sessionStorage.getItem('dispatch_token');
if (cached) {
const { token, expiresAt } = JSON.parse(cached);
if (new Date(expiresAt) > new Date()) {
return new DispatchPortal({ token });
}
}
const { token, expiresAt } = await fetch('/api/support/token').then(r => r.json());
sessionStorage.setItem('dispatch_token', JSON.stringify({ token, expiresAt }));
return new DispatchPortal({ token });
}
export function useTickets() {
return useQuery({
queryKey: ['tickets'],
queryFn: async () => {
const portal = await getPortal();
return portal.tickets.list();
},
});
}
export function useCreateTicket() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { title: string; body?: string }) => {
const portal = await getPortal();
return portal.tickets.create(data, {
idempotencyKey: crypto.randomUUID(),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
},
});
}File Uploads
Allow users to attach files to tickets. Files are uploaded directly to storage via presigned URLs, then linked to tickets.
Upload Flow
For new tickets, upload files first as "pending" attachments, then include them when creating the ticket:
async function createTicketWithFiles(
portal: DispatchPortal,
title: string,
body: string,
files: File[]
) {
const attachmentIds: string[] = [];
// 1. Upload each file
for (const file of files) {
// Get presigned upload URL
const { attachment, uploadUrl } = await portal.tickets.initiatePendingUpload({
filename: file.name,
contentType: file.type,
size: file.size,
});
// Upload directly to storage (not through API)
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
// Confirm upload completed
await portal.tickets.confirmPendingUpload(attachment.id);
attachmentIds.push(attachment.id);
}
// 2. Create ticket with attachment IDs
return portal.tickets.create({ title, body, attachmentIds });
}Add Files to Existing Ticket
// Upload to an existing ticket
const { attachment, uploadUrl } = await portal.tickets.initiateUpload(ticketId, {
filename: file.name,
contentType: file.type,
size: file.size,
});
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
await portal.tickets.confirmUpload(ticketId, attachment.id);File Limits
| Max file size | 50 MB per file |
| Allowed types | Images, PDFs, documents, videos, audio, archives |
Webhooks
Get notified when tickets are updated.
Setup Webhook Endpoint
import { DispatchTickets } from '@dispatchtickets/sdk';
app.post('/webhooks/dispatch', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-dispatch-signature'] as string;
const rawBody = req.body.toString();
// Verify signature
const isValid = DispatchTickets.webhooks.verifySignature(
rawBody,
signature,
process.env.DISPATCH_WEBHOOK_SECRET!
);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody);
switch (event.type) {
case 'ticket.created':
console.log('New ticket:', event.data.ticket.id);
break;
case 'ticket.updated':
console.log('Ticket updated:', event.data.ticket.id);
break;
case 'ticket.comment.created':
// Notify user if an agent replied
if (event.data.comment.authorType === 'AGENT') {
notifyUser(event.data.customerEmail, event.data.ticketId);
}
break;
}
res.json({ received: true });
});Available Events
| Event | Description |
|---|---|
ticket.created | New ticket created |
ticket.updated | Ticket status, priority, or assignment changed |
ticket.comment.created | New comment added |
Error Handling
The SDK provides typed errors with helpful context:
import {
isNotFoundError,
isValidationError,
isRateLimitError,
isAuthenticationError,
} from '@dispatchtickets/sdk';
try {
const ticket = await portal.tickets.get(ticketId);
} catch (error) {
if (isNotFoundError(error)) {
// Ticket doesn't exist or user doesn't have access
return res.status(404).json({ error: 'Ticket not found' });
}
if (isValidationError(error)) {
// Invalid input
return res.status(400).json({ error: error.message, fields: error.errors });
}
if (isRateLimitError(error)) {
// Too many requests - error.retryAfter has seconds to wait
return res.status(429).json({
error: 'Too many requests',
retryAfter: error.retryAfter,
});
}
if (isAuthenticationError(error)) {
// Token expired or invalid
return res.status(401).json({ error: 'Authentication failed' });
}
// Unknown error
console.error('Dispatch error:', error);
return res.status(500).json({ error: 'Support service unavailable' });
}API Reference
Ticket List Response
interface TicketListResponse {
data: Ticket[];
pagination: {
hasMore: boolean;
nextCursor: string | null;
};
}
interface Ticket {
id: string;
ticketNumber: string; // e.g., "TKT-1001"
title: string;
status: string; // 'open' | 'pending' | 'resolved' | 'closed'
priority: string; // 'low' | 'normal' | 'high' | 'urgent'
commentCount: number;
createdAt: string;
updatedAt: string;
}Ticket Detail Response
interface TicketDetail extends Ticket {
body: string;
brand: { name: string };
comments: Comment[];
}
interface Comment {
id: string;
body: string;
authorType: 'CUSTOMER' | 'STAFF';
authorName: string;
createdAt: string;
}Portal Methods
const portal = new DispatchPortal({ token });
// List tickets (paginated)
await portal.tickets.list({ status?: string, limit?: number, cursor?: string });
// Iterate all tickets
for await (const ticket of portal.tickets.listAll({ status: 'open' })) {
console.log(ticket.title);
}
// Get single ticket with comments
await portal.tickets.get(ticketId);
// Create ticket (with optional attachments)
await portal.tickets.create({ title, body?, attachmentIds? }, { idempotencyKey? });
// Add comment
await portal.tickets.addComment(ticketId, body, { idempotencyKey? });
// Refresh token (extend expiry)
const { token: newToken } = await portal.refresh();
// --- Attachments ---
// Upload file before creating ticket (pending attachment)
const { attachment, uploadUrl } = await portal.tickets.initiatePendingUpload({ filename, contentType, size });
await fetch(uploadUrl, { method: 'PUT', body: fileBuffer });
await portal.tickets.confirmPendingUpload(attachment.id);
// Upload file to existing ticket
const { attachment, uploadUrl } = await portal.tickets.initiateUpload(ticketId, { filename, contentType, size });
await fetch(uploadUrl, { method: 'PUT', body: fileBuffer });
await portal.tickets.confirmUpload(ticketId, attachment.id);
// List/get attachments
await portal.tickets.listAttachments(ticketId);
await portal.tickets.getAttachment(ticketId, attachmentId); // includes downloadUrlCommon Issues
Token Expiration
Portal tokens expire after 1 hour. Handle this by caching tokens for ~50 minutes (backend proxy) or catching auth errors and fetching a new token (direct frontend).
try {
await portal.tickets.list();
} catch (error) {
if (isAuthenticationError(error)) {
// Token expired - get a new one
const { token } = await fetch('/api/support/token').then(r => r.json());
portal = new DispatchPortal({ token });
await portal.tickets.list(); // Retry
}
}Duplicate Tickets/Comments
Network retries can cause duplicates. Always use idempotency keys:
await portal.tickets.create(data, {
idempotencyKey: `create-${userId}-${Date.now()}`,
});Webhook Signature Failures
The signature is computed on the raw request body. Make sure body parsers don't modify it:
// Express - use raw body for webhook route
app.use('/webhooks/dispatch', express.raw({ type: 'application/json' }));
// Then in handler, req.body is a Buffer
const rawBody = req.body.toString();CORS Errors (Direct Frontend)
If you get CORS errors when calling from the browser, ensure portalOrigins is configured for your brand:
await admin.brands.update(brandId, {
portalOrigins: [
'https://yourapp.com',
'https://www.yourapp.com',
'http://localhost:3000', // For development
],
});Environment Variables
# Required
DISPATCH_API_KEY=sk_live_xxx # From Dispatch dashboard
DISPATCH_BRAND_ID=br_xxx # Your brand ID
# For webhooks
DISPATCH_WEBHOOK_SECRET=whsec_xxx # From webhook settings