Skip to main content

Toast Integration

Overview

Toast is a leading cloud-based restaurant POS system with strong presence in the US market (46% in Vermont). Integration uses their Partner API with machine client authentication.


Authentication

Partner Program

Toast requires partner program membership for API access. The integration uses machine client credentials (server-to-server auth) rather than OAuth user flows.

Authentication Flow

Implementation

// src/integrations/toast/auth.ts

interface ToastCredentials {
clientId: string;
clientSecret: string;
}

interface ToastAuthResponse {
token?: {
accessToken: string;
expiresIn: number; // seconds
};
error?: string;
}

const TOAST_AUTH_URL = 'https://ws-api.toasttab.com/authentication/v1/authentication/login';

export async function authenticateToast(
credentials: ToastCredentials
): Promise<string> {
const response = await fetch(TOAST_AUTH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Pivot-POS-Service',
},
body: JSON.stringify({
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
accessType: 'TOAST_MACHINE_CLIENT',
}),
});

const data: ToastAuthResponse = await response.json();

if (data.error) {
throw new Error(`Toast authentication failed: ${data.error}`);
}

if (!data.token?.accessToken) {
throw new Error('Toast authentication returned no token');
}

return data.token.accessToken;
}

Required Headers

All API calls require:

const headers = {
'Authorization': `Bearer ${accessToken}`,
'Toast-Restaurant-External-ID': restaurantGuid,
'User-Agent': 'Pivot-POS-Service',
'Content-Type': 'application/json',
};

API Endpoints

Base URL

https://ws-api.toasttab.com

Endpoints Used

EndpointMethodPurpose
/authentication/v1/authentication/loginPOSTGet access token
/labor/v1/employeesGETEmployee roster
/labor/v1/timeEntriesGETTime clock data
/orders/v2/ordersGETList order IDs by date range
/orders/v2/orders/{orderGuid}GETOrder details (sales, tips)
/config/v2/salesCategoriesGETSales categories
/menus/v2/menusGETMenu items

Rate Limiting

Toast enforces strict rate limits:

LimitValueScope
Per second20 requestsPer restaurant OR per IP
Per 15 minutes10,000 requestsPer restaurant OR per IP
Menu endpoint1 request/secondPer location
OrdersBulk5 requests/secondPer client per location

Rate Limit Headers

X-Toast-RateLimit-By: RESTAURANT|IP
X-Toast-RateLimit-Remaining: 9500
X-Toast-RateLimit-Reset: 1701523200

Handling 429 Responses

async function handleRateLimit(response: Response): Promise<void> {
if (response.status === 429) {
const resetTime = response.headers.get('X-Toast-RateLimit-Reset');
const retryAfter = response.headers.get('Retry-After');

const waitMs = retryAfter
? parseInt(retryAfter) * 1000
: resetTime
? (parseInt(resetTime) * 1000) - Date.now()
: 60000; // Default 1 minute

logger.warn('Rate limited by Toast', { waitMs });
await sleep(Math.max(waitMs, 1000));
}
}

Data Sync Strategy

Sync Order

  1. Authenticate - Get fresh access token
  2. Employees - Full roster sync (no incremental)
  3. Time Entries - Filter by date range
  4. Orders - Hour-by-hour iteration for incremental
  5. Order Details - Fetch each order individually

Hour-by-Hour Order Fetching

Toast's orders endpoint only returns order GUIDs, requiring individual fetches:

async function fetchOrders(
accessToken: string,
restaurantGuid: string,
since: Date
): Promise<ToastOrder[]> {
const orders: ToastOrder[] = [];
let currentHour = since;
const now = new Date();

// Iterate hour by hour (Toast limitation)
while (currentHour < now) {
const nextHour = addHours(currentHour, 1);

// Get order IDs for this hour
const orderIds = await fetchOrderIds(
accessToken,
restaurantGuid,
currentHour,
nextHour
);

// Fetch each order's details
for (const orderId of orderIds) {
await rateLimiter.throttle();

const order = await fetchOrderDetails(
accessToken,
restaurantGuid,
orderId
);

if (order) {
orders.push(order);
}
}

currentHour = nextHour;
}

return orders;
}

Incremental Sync Logic

async function syncToastData(
companyId: string,
restaurantGuid: string
): Promise<SyncResult> {
const metadata = await getSyncMetadata(companyId, 'toast');
const since = metadata?.lastSuccessfulSync || subDays(new Date(), 7);

logger.info('Starting Toast sync', {
companyId,
since: since.toISOString(),
});

// Authenticate
const accessToken = await authenticateToast(await getCredentials());

// Fetch all data types
const [employees, timeEntries, orders] = await Promise.all([
fetchEmployees(accessToken, restaurantGuid),
fetchTimeEntries(accessToken, restaurantGuid, since),
fetchOrders(accessToken, restaurantGuid, since),
]);

// Transform to unified models
const unifiedEmployees = employees.map(e => mapToastEmployee(e, companyId));
const unifiedTimeClocks = timeEntries.map(t => mapToastTimeClock(t, companyId));
const { sales, tips } = extractSalesAndTips(orders, companyId);

// Insert to BigQuery
await Promise.all([
insertEmployees(unifiedEmployees),
insertTimeClocks(unifiedTimeClocks),
insertSales(sales),
insertTips(tips),
]);

// Update sync metadata
await updateSyncMetadata(companyId, 'toast', {
lastSuccessfulSync: new Date(),
employeesSynced: unifiedEmployees.length,
timeClocksSynced: unifiedTimeClocks.length,
salesSynced: sales.length,
tipsSynced: tips.length,
});

return {
employees: unifiedEmployees.length,
timeClocks: unifiedTimeClocks.length,
sales: sales.length,
tips: tips.length,
};
}

Data Mapping

Employee Mapping

// Toast employee response
interface ToastEmployee {
guid: string;
firstName: string;
lastName: string;
email?: string;
phoneNumber?: string;
jobReferences: Array<{
guid: string;
title: string;
code?: string;
}>;
createdDate: string;
modifiedDate: string;
deletedDate?: string;
}

function mapToastEmployee(
toast: ToastEmployee,
companyId: string
): UnifiedEmployee {
return {
id: generateUUID(),
posEmployeeId: toast.guid,
companyId,
posType: 'toast',

firstName: toast.firstName,
lastName: toast.lastName,
fullName: `${toast.firstName} ${toast.lastName}`.trim(),
email: toast.email,
phone: toast.phoneNumber,

jobCodes: toast.jobReferences.map(j => j.code).filter(Boolean),
jobTitles: toast.jobReferences.map(j => j.title),
hireDate: toast.createdDate ? new Date(toast.createdDate) : undefined,
terminationDate: toast.deletedDate ? new Date(toast.deletedDate) : undefined,
isActive: !toast.deletedDate,

createdAt: new Date(),
updatedAt: new Date(),
syncedAt: new Date(),
rawData: toast,
};
}

Time Clock Mapping

// Toast time entry response
interface ToastTimeEntry {
guid: string;
employeeReference: { guid: string };
inDate: string;
outDate?: string;
regularHours?: number;
overtimeHours?: number;
jobReference?: { guid: string; title: string };
}

function mapToastTimeClock(
toast: ToastTimeEntry,
companyId: string
): UnifiedTimeClock {
const clockIn = new Date(toast.inDate);
const clockOut = toast.outDate ? new Date(toast.outDate) : undefined;

return {
id: generateUUID(),
posPunchId: toast.guid,
companyId,
posType: 'toast',

employeeId: '', // Resolved later via lookup
posEmployeeId: toast.employeeReference.guid,

clockIn,
clockOut,
breakMinutes: 0, // Toast doesn't provide break data

shiftDate: startOfDay(clockIn),
hoursWorked: toast.regularHours
? toast.regularHours + (toast.overtimeHours || 0)
: clockOut
? differenceInHours(clockOut, clockIn)
: undefined,

locationId: undefined,
department: undefined,
jobCode: toast.jobReference?.guid,

syncedAt: new Date(),
rawData: toast,
};
}

Sales & Tips Extraction

// Toast order structure
interface ToastOrder {
guid: string;
openedDate: string;
modifiedDate: string;
checks: Array<{
guid: string;
voided: boolean;
deleted: boolean;
amount: number;
tipAmount?: number;
employeeReference?: { guid: string };
selections: Array<{
guid: string;
displayName: string;
quantity: number;
price: number;
voided?: boolean;
selectionType: string;
saleCategory?: { guid: string; name: string };
}>;
}>;
}

function extractSalesAndTips(
orders: ToastOrder[],
companyId: string
): { sales: UnifiedSale[]; tips: UnifiedTip[] } {
const sales: UnifiedSale[] = [];
const tips: UnifiedTip[] = [];

for (const order of orders) {
const transactionTime = new Date(order.openedDate);
const transactionDate = startOfDay(transactionTime);

for (const check of order.checks) {
// Skip voided/deleted checks
if (check.voided || check.deleted) continue;

const employeeId = check.employeeReference?.guid;

// Extract sales from selections
for (const selection of check.selections) {
if (selection.voided) continue;

// Skip non-item selections
if (['SPECIAL_REQUEST', 'NONE'].includes(selection.selectionType)) {
continue;
}

sales.push({
id: generateUUID(),
posTransactionId: order.guid,
posLineId: selection.guid,
companyId,
posType: 'toast',

employeeId: '', // Resolved later
posEmployeeId: employeeId,

transactionTime,
transactionDate,

itemName: selection.displayName,
itemCategory: selection.saleCategory?.name,
pluNumber: selection.guid,
quantity: selection.quantity,

grossAmount: selection.price * selection.quantity,
discountAmount: 0, // Would need discount data
netAmount: selection.price * selection.quantity,
taxAmount: 0, // Would need tax data

isVoided: false,
isRefund: selection.price < 0,
isModifier: false,
parentLineId: undefined,

syncedAt: new Date(),
rawData: selection,
});
}

// Extract tips
if (check.tipAmount && check.tipAmount > 0 && employeeId) {
tips.push({
id: generateUUID(),
posTransactionId: order.guid,
companyId,
posType: 'toast',

employeeId: '', // Resolved later
posEmployeeId: employeeId,

transactionTime,
transactionDate,

tipAmount: check.tipAmount,
tipType: 'credit_card', // Toast tips are typically credit card

relatedCheckId: check.guid,
checkAmount: check.amount,

syncedAt: new Date(),
rawData: { checkGuid: check.guid, tipAmount: check.tipAmount },
});
}
}
}

return { sales, tips };
}

Error Handling

Common Error Scenarios

ErrorCauseResolution
401 UnauthorizedToken expired or invalidRe-authenticate
403 ForbiddenMissing restaurant accessVerify partner permissions
404 Not FoundInvalid restaurant GUIDVerify configuration
429 Rate LimitedToo many requestsWait and retry
500 Server ErrorToast API issueRetry with backoff

Error Recovery

async function syncWithRecovery(
companyId: string,
restaurantGuid: string
): Promise<SyncResult> {
try {
return await syncToastData(companyId, restaurantGuid);
} catch (error) {
if (isAuthError(error)) {
// Clear cached token and retry once
clearTokenCache(companyId);
return await syncToastData(companyId, restaurantGuid);
}

if (isRateLimitError(error)) {
// Already handled in fetch layer, but log
logger.warn('Sync aborted due to rate limiting', { companyId });
throw error;
}

if (isServerError(error)) {
// Toast is having issues, schedule retry
await scheduleRetry(companyId, 'toast', 15); // 15 minutes
throw error;
}

// Unknown error
logger.error('Unexpected Toast sync error', {
companyId,
error: error.message,
stack: error.stack,
});
throw error;
}
}

Configuration

Per-Company Settings

Stored in Firebase RTDB (existing Pivot pattern):

{
"PosSettings": {
"companyId123": {
"posType": "toast",
"toast": {
"restaurantGuid": "abc-123-def-456",
"isActive": true,
"lastSync": "2025-12-01T10:00:00Z"
}
}
}
}

Service Configuration

Environment variables in Cloud Run:

# Required
TOAST_CLIENT_ID=partner-pivot
TOAST_CLIENT_SECRET=<from Secret Manager>

# Optional
TOAST_API_BASE_URL=https://ws-api.toasttab.com
TOAST_AUTH_URL=https://ws-api.toasttab.com/authentication/v1/authentication/login

Testing

Unit Tests

describe('Toast Employee Mapping', () => {
it('should map active employee correctly', () => {
const toastEmployee = {
guid: 'emp-123',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
jobReferences: [
{ guid: 'job-1', title: 'Server', code: 'SRV' }
],
createdDate: '2024-01-15T00:00:00Z',
modifiedDate: '2025-01-01T00:00:00Z',
};

const result = mapToastEmployee(toastEmployee, 'company-456');

expect(result.posEmployeeId).toBe('emp-123');
expect(result.fullName).toBe('John Doe');
expect(result.isActive).toBe(true);
expect(result.jobTitles).toContain('Server');
});

it('should handle terminated employee', () => {
const toastEmployee = {
guid: 'emp-456',
firstName: 'Jane',
lastName: 'Smith',
deletedDate: '2025-06-01T00:00:00Z',
jobReferences: [],
createdDate: '2024-01-15T00:00:00Z',
modifiedDate: '2025-06-01T00:00:00Z',
};

const result = mapToastEmployee(toastEmployee, 'company-456');

expect(result.isActive).toBe(false);
expect(result.terminationDate).toEqual(new Date('2025-06-01T00:00:00Z'));
});
});

Integration Tests

describe('Toast API Integration', () => {
const testRestaurantGuid = process.env.TOAST_TEST_RESTAURANT_GUID;

beforeAll(() => {
if (!testRestaurantGuid) {
throw new Error('TOAST_TEST_RESTAURANT_GUID required for integration tests');
}
});

it('should authenticate successfully', async () => {
const token = await authenticateToast({
clientId: process.env.TOAST_CLIENT_ID!,
clientSecret: process.env.TOAST_CLIENT_SECRET!,
});

expect(token).toBeTruthy();
expect(typeof token).toBe('string');
});

it('should fetch employees', async () => {
const token = await authenticateToast({...});
const employees = await fetchEmployees(token, testRestaurantGuid);

expect(Array.isArray(employees)).toBe(true);
if (employees.length > 0) {
expect(employees[0]).toHaveProperty('guid');
expect(employees[0]).toHaveProperty('firstName');
}
});
});

Monitoring

Key Metrics

MetricDescriptionAlert Threshold
toast_auth_successAuthentication success rate<95%
toast_api_latencyAPI response timeP95 > 5s
toast_sync_durationFull sync time>30 minutes
toast_records_syncedRecords per syncSudden drop
toast_rate_limit_hits429 responses>10/hour

Logging Examples

// Sync start
logger.info('Toast sync started', {
companyId,
restaurantGuid,
syncType: 'incremental',
since: since.toISOString(),
});

// API call
logger.debug('Toast API request', {
endpoint: '/orders/v2/orders',
restaurantGuid,
params: { startDate, endDate },
});

// Rate limit
logger.warn('Toast rate limit approaching', {
remaining: 100,
resetIn: '45 seconds',
});

// Sync complete
logger.info('Toast sync completed', {
companyId,
duration: 45.2,
records: { employees: 25, sales: 1200 },
});