Back to Docs

Integrating into Your App

Add a complete support ticketing system to your app in minutes.

Quick Start

npm install @dispatchtickets/sdk
Basic usage
import { 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:

APIAuthPurpose
Admin APIsk_live_xxxManage tickets, customers, brands. Used by your backend.
Portal APIPortal TokenEnd-users view/create their own tickets. Scoped to one customer.

Integration Patterns

Choose based on your needs:

PatternComplexityBest For
Backend ProxyMediumMaximum security, SSR apps, complex auth
Direct FrontendSimpleSPAs, 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

lib/dispatch.ts
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

Express / Node.js example
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
// 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:

One-time setup
// Allow your frontend domain
await admin.brands.update(brandId, {
  portalOrigins: ['https://yourapp.com', 'https://www.yourapp.com'],
});

Backend: Token Endpoint

Single endpoint to get a portal token
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

Use the SDK directly in the browser
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

hooks/useTickets.ts
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:

Upload files with a new 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 size50 MB per file
Allowed typesImages, PDFs, documents, videos, audio, archives

Webhooks

Get notified when tickets are updated.

Setup Webhook Endpoint

Important: Use raw body for signature verification
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

EventDescription
ticket.createdNew ticket created
ticket.updatedTicket status, priority, or assignment changed
ticket.comment.createdNew 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 downloadUrl

Common 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

Ready to build?

Join the waitlist and start integrating in minutes.