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
| Endpoint | Method | Purpose |
|---|---|---|
/authentication/v1/authentication/login | POST | Get access token |
/labor/v1/employees | GET | Employee roster |
/labor/v1/timeEntries | GET | Time clock data |
/orders/v2/orders | GET | List order IDs by date range |
/orders/v2/orders/{orderGuid} | GET | Order details (sales, tips) |
/config/v2/salesCategories | GET | Sales categories |
/menus/v2/menus | GET | Menu items |
Rate Limiting
Toast enforces strict rate limits:
| Limit | Value | Scope |
|---|---|---|
| Per second | 20 requests | Per restaurant OR per IP |
| Per 15 minutes | 10,000 requests | Per restaurant OR per IP |
| Menu endpoint | 1 request/second | Per location |
| OrdersBulk | 5 requests/second | Per 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
- Authenticate - Get fresh access token
- Employees - Full roster sync (no incremental)
- Time Entries - Filter by date range
- Orders - Hour-by-hour iteration for incremental
- 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
| Error | Cause | Resolution |
|---|---|---|
| 401 Unauthorized | Token expired or invalid | Re-authenticate |
| 403 Forbidden | Missing restaurant access | Verify partner permissions |
| 404 Not Found | Invalid restaurant GUID | Verify configuration |
| 429 Rate Limited | Too many requests | Wait and retry |
| 500 Server Error | Toast API issue | Retry 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
| Metric | Description | Alert Threshold |
|---|---|---|
toast_auth_success | Authentication success rate | <95% |
toast_api_latency | API response time | P95 > 5s |
toast_sync_duration | Full sync time | >30 minutes |
toast_records_synced | Records per sync | Sudden drop |
toast_rate_limit_hits | 429 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 },
});