AR Ticket Triager

Dynamic AR ticket routing system that instantly assigns tickets to the right team member based on availability, workload, and expertise.

Overview

This automation dynamically routes incoming Accounts Receivable (AR) tickets to the right inbox and assignee instantly. The system uses JavaScript-based logic to factor in availability, timezone, and ticket load for balanced assignment.

Full Zapier Workflow

Zoom: 30% | Use mouse wheel or pinch to zoom | Click and drag to pan
Full Zapier Workflow workflow diagram

Impact & Metrics

Tickets Processed

Over 9,000 tickets

Massive Scale

Successfully triaged thousands of AR tickets since deployment

Assignment Time

100% faster

16.7 hrs → 0.0 hrs

Instant ticket assignment eliminates manual triage delays

Response Time

55% improvement

68.0 hrs → 30.3 hrs

Faster first reply times improve customer experience

Resolution Time

64% improvement

95.2 hrs → 33.9 hrs

Dramatically faster full resolution times

Agent Productivity

8% increase

214 → 232 tickets

More tickets solved per agent with better workload distribution

Zero Unassigned

Since launch

Perfect Coverage

Every ticket gets assigned immediately with no manual intervention

Lessons Learned

Learning to Debug Production Systems

The first major issue hit me when tickets weren't getting assigned to anyone. At first, I thought it was a logic error in my assignment rules. It wasn't. When two tickets came in almost simultaneously, both runs of the Zap would check who had the lowest workload at the exact same moment, and somehow in that collision, the assignment would fail completely and the ticket would sit unassigned.

I had no idea what was happening. The error messages weren't helpful, and the behavior was completely inconsistent. After digging through forums and Googling, I learned I was dealing with something called a race condition. I'd never encountered this concept before, but once I understood it, the fix became clear. I added intentional delays to stagger API calls and moved the load calculation into one code step that would run all the way through before moving on to assignment.

Solving the API Rate Limit Problem

To check each agent's current ticket load, I initially used Zapier's standard webhook configuration. It kept failing and I couldn't figure out why. I'd test it, see the failure, tweak the logic, test again, same issue. It wasn't until I looked at one agent's ticket queue and realized they had a massive number of open tickets that it clicked: I was hitting Zendesk's API rate limits. The solution was switching to a custom API request setup where I could batch queries more efficiently and control the request frequency.

I also learned to write modular code within the Zap. Instead of cramming all the assignment logic into one giant code block, I created separate code steps for each ticket category. Each step handled its own load calculation, timezone checks, and agent selection logic. This made debugging much easier because I could test each category's assignment logic independently.

Designing for Operational Reality

As the system matured, I learned that theoretical elegance often collides with operational complexity. The routing logic couldn't be one-size-fits-all. It needed to accommodate global teams, timezone constraints, and transitional periods.

I built time-based eligibility directly into the assignment algorithm, defining specific working hour windows for each agent and checking real-time conditions before routing. This prevented overnight tickets from sitting unassigned or agents receiving work after logging off.

For exceptions like onboarding periods or schedule changes, explicit exclusions proved clearer than complex conditional logic. Temporarily filtering specific agents from certain rotations with a single, well-commented line of code was more reliable and easier to update when circumstances changed.

The Human Element

Working closely with the AR team taught me that automation design has to account for human trust. Technical decisions like protecting newer team members from overflow during training, ensuring international agents didn't receive assignments after hours, and creating retry mechanisms so staff didn't return to chaos after holidays were about agent experience, not just efficiency.

Technical Deep Dive

Load-Based Assignment Algorithm

The core assignment logic uses a sophisticated load-balancing algorithm that considers agent availability, current workload, and timezone-aware working hours. This prevents race conditions by calculating assignments atomically within a single code step.

// Parse inputs from Zapier
const names = inputData.names.split(",").map(n => n.trim());
const available = inputData.available.split(",").map(a => a.trim().toLowerCase() === "true");

// Map agent open ticket counts from individually named inputs
const openCounts = {
  "Agent1": parseInt(inputData.agent1_open, 10) || 0,
  "Agent2": parseInt(inputData.agent2_open, 10) || 0,
  "Agent3": parseInt(inputData.agent3_open, 10) || 0,
  "Agent4": parseInt(inputData.agent4_open, 10) || 0
};

// Get current hour in ET (timezone-aware)
const now = new Date();
const etHour = now.toLocaleString("en-US", {
  timeZone: "America/Toronto",
  hour: "numeric",
  hour12: false
});
const currentHour = parseInt(etHour, 10);

// Define working hours for each agent
const workingHours = {
  "Agent1": currentHour >= 4 && currentHour < 20,   // 4am-8pm ET
  "Agent2": true,  // Toronto-based, no restrictions
  "Agent3": true,   // Toronto-based, no restrictions
  "Agent4": true    // Toronto-based, no restrictions
};

// Build agent objects with availability and load data
const agents = names
  .filter(name => name !== "Agent5")  // Temporarily exclude Agent5
  .map((name, index) => ({
    name,
    available: available[names.indexOf(name)],
    openTickets: openCounts[name] ?? 0,
    inWorkingHours: workingHours[name] ?? true
  }));

// Filter for eligible agents (available AND within working hours)
const eligible = agents.filter(agent => agent.available && agent.inWorkingHours);

// Assignment logic with tie-breaking
let assignee = "NO_ONE_AVAILABLE";
let tied = [];
let reason = "";

if (eligible.length > 0) {
  const minOpen = Math.min(...eligible.map(a => a.openTickets));
  tied = eligible.filter(a => a.openTickets === minOpen);

  if (tied.length === 1) {
    assignee = tied[0].name;
    reason = `Assigned to ${assignee} with lowest load (${minOpen} open tickets)`;
  } else {
    // Random selection among tied agents
    const randomIndex = Math.floor(Math.random() * tied.length);
    assignee = tied[randomIndex].name;
    reason = `Randomly assigned among ${tied.length} agents tied at ${minOpen} open tickets`;
  }
} else {
  reason = "No agents available";
}

return {
  assignee,
  eligibleAgents: eligible.map(a => `${a.name}: ${a.openTickets} open`),
  tiedAtMin: tied.map(a => a.name),
  reason,
  currentHour: `${currentHour}:00 ET`
};

Timezone-Aware Business Logic

The most critical design decision was treating timezone availability as strategic business logic, not just a simple on/off flag. With team members working across different time zones and schedules, I built time-based eligibility directly into the assignment algorithm.

// Timezone-aware working hours validation
const isWithinWorkingHours = (agent, currentTime = new Date()) => {
  const agentTimezone = agent.timezone || 'UTC';
  const localTime = new Date(currentTime.toLocaleString("en-US", {timeZone: agentTimezone}));
  const localHour = localTime.getHours();
  const localDay = localTime.getDay(); // 0 = Sunday, 6 = Saturday
  
  // Weekend check
  if (localDay === 0 || localDay === 6) {
    return { 
      available: false, 
      reason: `Weekend in ${agentTimezone} (local time: ${localTime.toLocaleString()})` 
    };
  }
  
  // Working hours check (9 AM - 5 PM local time)
  if (localHour < 9 || localHour >= 17) {
    return { 
      available: false, 
      reason: `Outside working hours in ${agentTimezone} (local time: ${localTime.toLocaleString()})` 
    };
  }
  
  return { available: true, reason: "Within working hours" };
};

// Enhanced agent filtering with detailed reasoning
const filterAvailableAgents = (agents) => {
  const results = {
    available: [],
    filtered: [],
    summary: {}
  };
  
  agents.forEach(agent => {
    const timeCheck = isWithinWorkingHours(agent);
    const statusCheck = agent.status === 'available';
    const oooCheck = !agent.ooo_until || new Date(agent.ooo_until) <= new Date();
    
    if (timeCheck.available && statusCheck && oooCheck) {
      results.available.push(agent);
    } else {
      results.filtered.push({
        agent: agent.name,
        reasons: [
          !timeCheck.available ? timeCheck.reason : null,
          !statusCheck ? "Marked as unavailable" : null,
          !oooCheck ? `OOO until ${agent.ooo_until}` : null
        ].filter(Boolean)
      });
    }
  });
  
  results.summary = {
    total_agents: agents.length,
    available_count: results.available.length,
    filtered_count: results.filtered.length
  };
  
  return results;
};

System Architecture & Error Handling

The system uses a modular approach with separate Zaps for different ticket types, each with their own load-balancing logic. Error handling includes fallback assignments and detailed logging for debugging. The 1-minute delay prevents race conditions by ensuring data consistency before assignment logic runs.

Modular Design: Separate code steps for different ticket types (PO, Credit Card, Dunning) with shared load-balancing logic
Race Condition Prevention: 1-minute delay step ensures all parallel data gathering completes before assignment
Comprehensive Logging: Every assignment logged to Google Sheets with detailed reasoning for audit trails
Fallback Handling: When no agents available, tickets are tagged for manual review with clear reasoning