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 },
});