DEV Community

Cover image for Automated Ticket Routing by Timezone (with Code)
ABDULLAH AFZAL
ABDULLAH AFZAL

Posted on

Automated Ticket Routing by Timezone (with Code)

Automated ticket routing usually means sorting by topic or skill. This one sorts by clock: send each ticket to a support region that's actually staffed right now.

The hard part isn't the routing. It's knowing who's online, and "online" is really two things stacked together: what time it is in each region, and the hours you actually staff there. The first you can get exactly right from an API. The second you define. Put them together and you get follow-the-sun support without running a night shift in every office.

By the end you'll have a small, DST-safe router in TypeScript. You model your support hubs, pull each hub's current local time, pick a region that's open, and fall back sanely when none of them are.

TL;DR

  • "Route to whoever's online" = match a ticket to a hub that's inside its coverage window, computed from that hub's real local time.
  • An API gives you the DST-correct current time per timezone. Your config gives you the staffed hours. Neither one alone is enough.
  • Hardcoding UTC offsets breaks twice a year when daylight saving flips. Let the timezone data carry the offset.
  • Compare the hub's local wall-clock string against your window. Don't rebuild Date objects from offset strings; that's where the bugs live.
  • Always have a fallback hub for the hours nobody's open, so a ticket never lands nowhere.

Follow-the-sun routing sounds like an ops problem, and the strategy side of it is. But the piece nobody writes down is the implementation: the timezone math, the coverage windows, and the "it's 3am everywhere" edge case. That's what this builds.

What "route to whoever's online" actually needs

Strip it down and every follow-the-sun router answers one question per ticket: which of my hubs is open right now, and if more than one, which should take it?

"Open right now" depends on the hub's local time, not yours or your server's. A hub in Sydney and a hub in Berlin are nine or ten hours apart, and that gap shifts when either side changes for daylight saving. So the router needs the current local time in each hub, and it needs it to already account for DST. That last part is the whole reason to lean on a timezone service instead of a lookup table.

One thing worth saying plainly, because it's easy to oversell: an API can't tell you an agent is at their desk. It tells you it's 10:37 in Berlin and DST is off. Whether someone is working at 10:37 is your coverage policy, and if you want true presence you wire in your own on-call or status system. This tutorial routes on coverage windows, which is what most teams actually mean by "online."

Why the naive version breaks

The tempting first cut is a hardcoded offset. It looks fine in April and quietly misroutes every summer.

// The tempting version. It has a bug that shows up twice a year.
function isBerlinOpen() {
  const utcHour = new Date().getUTCHours();
  const berlinHour = (utcHour + 1) % 24; // Berlin is UTC+1... except in summer
  return berlinHour >= 9 && berlinHour < 18;
}
Enter fullscreen mode Exit fullscreen mode

For roughly half the year Berlin is UTC+2, not +1. So from late March to late October this function is an hour off, and tickets get sent to an office that's still closed or skipped when it's actually staffed. Multiply that across five hubs and two DST calendars that don't switch on the same weekend, and the bug is basically undebuggable from the outside.

Server-local time has the same problem in a nicer outfit. Your box runs UTC in one region and local time in another, and new Date() means different things in each. Timezone libraries like Luxon or Intl.DateTimeFormat solve the math, but then you own keeping the IANA tz database current, and it does change through the year. The point isn't that any of these can't work. It's that the offset and the DST state are the error-prone part, so the cleanest move is to fetch them already resolved and never do the arithmetic yourself.

Pick a source for the timezone data

You've got two honest options. Compute it in-process from the IANA tz database with a library like Luxon or date-fns-tz, or call an API such as IPGeolocation, ipinfo, or ip-api that returns the current time and DST state for a zone directly. In-process is zero network latency but puts tz-data updates on you. An API call adds a hop but hands you the resolved offset, the DST flag, and the next transition.

I'll use IPGeolocation here because one call to its Timezone API returns a hub's local time, its DST status, and when DST next flips, and it's on the free tier, which keeps this example short. Create a free key, set it as IPGEO_API_KEY, and you're ready. Here's the request for one zone:

curl -X GET 'https://api.ipgeolocation.io/v3/timezone?apiKey=API_KEY&tz=America/New_York'
Enter fullscreen mode Exit fullscreen mode

The response carries the full timezone picture. This is the complete object, not a trimmed one, because the DST fields are the part that matters:

{
  "time_zone": {
    "name": "America/New_York",
    "offset": -5,
    "offset_with_dst": -5,
    "date": "2026-03-07",
    "date_time": "2026-03-07 04:37:39",
    "date_time_txt": "Saturday, March 07, 2026 04:37:39",
    "date_time_wti": "Sat, 07 Mar 2026 04:37:39 -0500",
    "date_time_ymd": "2026-03-07T04:37:39-0500",
    "current_time": "2026-03-07 04:37:39.746-0500",
    "current_time_unix": 1772876259.746,
    "time_24": "04:37:39",
    "time_12": "04:37:39 AM",
    "week": 10,
    "month": 3,
    "year": 2026,
    "year_abbr": "26",
    "current_tz_abbreviation": "EST",
    "current_tz_full_name": "Eastern Standard Time",
    "standard_tz_abbreviation": "EST",
    "standard_tz_full_name": "Eastern Standard Time",
    "is_dst": false,
    "dst_savings": 0,
    "dst_exists": true,
    "dst_tz_abbreviation": "EDT",
    "dst_tz_full_name": "Eastern Daylight Time",
    "dst_start": { "utc_time": "2026-03-08 TIME 07:00", "duration": "+1.00H", "gap": true, "date_time_after": "2026-03-08 TIME 03:00", "date_time_before": "2026-03-08 TIME 02:00", "overlap": false },
    "dst_end": { "utc_time": "2026-11-01 TIME 06:00", "duration": "-1.00H", "gap": false, "date_time_after": "2026-11-01 TIME 01:00", "date_time_before": "2026-11-01 TIME 02:00", "overlap": true }
  }
}
Enter fullscreen mode Exit fullscreen mode

Look at dst_start: it's 2026-03-08, the day after this response. When that moment passes, is_dst flips to true and offset_with_dst becomes -4 on its own. You don't track that; the API does. For routing, the two fields I care about are time_24 (the local wall-clock time as a string) and date_time_txt (whose first token is the local weekday). I'll read the offset arithmetic off nobody.

Pitfall: don't build a Date from date_time_ymd. Its offset is formatted -0500 with no colon, which some JS engines parse and others reject. Comparing the time_24 string sidesteps timezone parsing entirely, so that's what the router uses.

Here's the client. It validates the key at startup and caches per zone, because a hub's clock only moves by the minute and you don't want a lookup per ticket at peak.

// timezone.ts - one dedicated call per zone, cached by the minute.
const API_KEY = process.env.IPGEO_API_KEY;
if (!API_KEY) {
  // Fail fast at boot, not on the first ticket at 2am.
  throw new Error("Missing IPGEO_API_KEY");
}

const BASE = "https://api.ipgeolocation.io/v3/timezone";
const TTL_MS = 30_000; // tickets arriving in the same 30s share one lookup
const cache = new Map<string, { at: number; data: TimeZone }>();

export interface TimeZone {
  name: string;
  time_24: string;        // "HH:mm:ss", local wall clock
  date: string;           // "YYYY-MM-DD", local date
  date_time_txt: string;  // "Saturday, March 07, 2026 09:37:39"
  is_dst: boolean;
  offset_with_dst: number;
}

export async function getZoneTime(tz: string): Promise<TimeZone> {
  const hit = cache.get(tz);
  if (hit && Date.now() - hit.at < TTL_MS) return hit.data;

  const url = `${BASE}?apiKey=${API_KEY}&tz=${encodeURIComponent(tz)}`;
  const res = await fetch(url, { signal: AbortSignal.timeout(1500) }); // Node 18+ has fetch
  if (!res.ok) throw new Error(`timezone lookup failed for ${tz}: ${res.status}`);

  const z = (await res.json())?.time_zone;
  if (!z?.time_24 || !z?.date_time_txt) throw new Error(`unexpected payload for ${tz}`);

  cache.set(tz, { at: Date.now(), data: z });
  return z;
}
Enter fullscreen mode Exit fullscreen mode

The AbortSignal.timeout(1500) matters more than it looks. Ticket creation shouldn't hang for a slow lookup, so cap it and let the caller decide what to do on failure.

Model your support hubs

Coverage belongs in config, not buried in the routing logic. A hub is an IANA zone, a set of staffed hours per weekday, a holiday list, a priority for tiebreaks, and optionally the languages it covers.

// hubs.ts - edit this file, not the router, when coverage changes.
export interface Hub {
  id: string;
  tz: string;                                        // IANA zone, passed straight to the API
  priority: number;                                  // lower wins when several hubs are open
  languages: string[];                               // prefer a hub that speaks the customer's language
  holidays: Set<string>;                             // "YYYY-MM-DD" in the hub's local date
  hours: Record<string, { open: string; close: string } | null>; // null = closed that weekday
}

const workdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"];
const nineToSix = Object.fromEntries(workdays.map((d) => [d, { open: "09:00", close: "18:00" }]));

export const HUBS: Hub[] = [
  { id: "emea", tz: "Europe/Berlin",     priority: 1, languages: ["de", "en"],
    holidays: new Set(["2026-10-03"]), hours: nineToSix },
  { id: "amer", tz: "America/New_York",  priority: 1, languages: ["en", "es"],
    holidays: new Set(["2026-07-03"]), hours: nineToSix },
  { id: "apac", tz: "Australia/Sydney",  priority: 2, languages: ["en"],
    holidays: new Set(["2026-01-26"]), hours: nineToSix },
];

export const FALLBACK_HUB_ID = "amer"; // whoever carries the pager when the world's asleep
Enter fullscreen mode Exit fullscreen mode

Note that hours keys are full weekday names, because that's what the API's date_time_txt gives us and matching strings beats mapping day numbers back and forth. Days you don't staff are simply absent, which reads as closed.

The ticket routing decision

Three steps: figure out which hubs are open, pick the best open one, and handle the case where none are.

Deciding if one hub is open

For a hub, pull its local time, read its local weekday, check today's window, and rule out holidays. The window check handles normal daytime hours and overnight coverage like 22:00 to 06:00, including the early-morning carryover from the previous local day.

// router.ts
import { HUBS, FALLBACK_HUB_ID, Hub } from "./hubs";
import { getZoneTime } from "./timezone";

const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];

const toMinutes = (hhmm: string) => {
  const [h, m] = hhmm.split(":").map(Number);
  return h * 60 + m;
};

function previousWeekday(day: string) {
  const i = WEEKDAYS.indexOf(day);
  return WEEKDAYS[(i + 6) % 7];
}

function previousLocalDate(yyyyMmDd: string) {
  const d = new Date(`${yyyyMmDd}T00:00:00Z`);
  d.setUTCDate(d.getUTCDate() - 1);
  return d.toISOString().slice(0, 10);
}

function sameDayWindow(nowHHMMSS: string, w: { open: string; close: string }) {
  const now = toMinutes(nowHHMMSS.slice(0, 5));
  const open = toMinutes(w.open);
  const close = toMinutes(w.close);

  // Normal window: 09:00-18:00.
  // Overnight window: only the start side belongs to this weekday, e.g. 22:00-23:59.
  return open <= close ? now >= open && now < close : now >= open;
}

function previousDayOvernightWindow(nowHHMMSS: string, w: { open: string; close: string }) {
  const now = toMinutes(nowHHMMSS.slice(0, 5));
  const open = toMinutes(w.open);
  const close = toMinutes(w.close);

  // Previous day's overnight carryover, e.g. Friday 22:00-06:00 still open Saturday 02:00.
  return open > close && now < close;
}

async function isOpenNow(hub: Hub): Promise<boolean | "unknown"> {
  try {
    const z = await getZoneTime(hub.tz);
    const weekday = z.date_time_txt.split(",")[0].trim();
    const window = hub.hours[weekday];

    if (window && !hub.holidays.has(z.date) && sameDayWindow(z.time_24, window)) {
      return true;
    }

    const prevWeekday = previousWeekday(weekday);
    const prevWindow = hub.hours[prevWeekday];
    const prevDate = previousLocalDate(z.date);

    if (
      prevWindow &&
      !hub.holidays.has(prevDate) &&
      previousDayOvernightWindow(z.time_24, prevWindow)
    ) {
      return true;
    }

    return false;
  } catch {
    return "unknown";
  }
}
Enter fullscreen mode Exit fullscreen mode

The "unknown" return is deliberate. A failed lookup is not the same as a closed office, and collapsing the two is how you either drop tickets or spam a hub that's asleep.

Picking among the open hubs

When several hubs are open, prefer one that speaks the customer's language, then fall back to priority, then a stable tiebreak so the same ticket always lands the same way.

export interface Routing { hubId: string; reason: string; }

export async function routeTicket(customerLangs: string[] = []): Promise<Routing> {
  const statuses = await Promise.all(
    HUBS.map(async (h) => ({ hub: h, open: await isOpenNow(h) }))
  );

  const openHubs = statuses.filter((s) => s.open === true).map((s) => s.hub);

  if (openHubs.length > 0) {
    openHubs.sort((a, b) => {
      const langA = a.languages.some((l) => customerLangs.includes(l)) ? 0 : 1;
      const langB = b.languages.some((l) => customerLangs.includes(l)) ? 0 : 1;
      return langA - langB || a.priority - b.priority || a.id.localeCompare(b.id);
    });
    return { hubId: openHubs[0].id, reason: "open, best language and priority match" };
  }

  // Nobody's open, or we couldn't tell. Route to the on-call hub, never to nowhere.
  return { hubId: FALLBACK_HUB_ID, reason: "no hub open, sent to on-call fallback" };
}
Enter fullscreen mode Exit fullscreen mode

The fallback, and fail-open vs fail-closed

This is the decision most versions skip. If no hub is open, or every lookup failed, the router still returns a destination: the fallback hub. For support, failing toward a human is the right call. Dropping a ticket because an API timed out is worse than sending it to your on-call team a little early. If you'd rather hold it until a region opens, you can compute the next-to-open hub from the same timezone data instead of using a fixed fallback, but I'd only add that once the simple version is running.

Tip: log the reason on every routed ticket. When someone asks why a 2am ticket went to New York, the answer is right there in the record.

Know the customer's local time too

Routing decides who handles the ticket. Knowing the customer's local time decides how the agent treats it. A billing question at 2pm their time is different from one that came in at 3am, and showing the agent that context is a small touch that changes tone.

The same endpoint takes an IP and returns the requester's location alongside the timezone block, so you get their city, country, and local time in one call.

// enrich.ts - tell the agent what time it is where the customer is.
const API_KEY = process.env.IPGEO_API_KEY!;
const BASE = "https://api.ipgeolocation.io/v3/timezone";
const toHour = (t: string) => Number(t.slice(0, 2));

export async function customerLocalTime(ip: string) {
  try {
    const url = `${BASE}?apiKey=${API_KEY}&ip=${encodeURIComponent(ip)}`;
    const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
    if (!res.ok) return null;                 // enrichment is non-critical, so degrade quietly
    const body = await res.json();
    const z = body?.time_zone;
    if (!z?.time_24) return null;
    return {
      city: body?.location?.city ?? "unknown",
      country: body?.location?.country_code2 ?? "??",
      zone: z.name,
      localTime: z.time_24,
      isNight: toHour(z.time_24) < 8 || toHour(z.time_24) >= 22,
    };
  } catch {
    return null; // never block ticket creation on an enrichment call
  }
}
Enter fullscreen mode Exit fullscreen mode

Enrichment returns null on any failure instead of throwing, because a missing "local time" note should never stop a ticket from being created.

Pitfall: if you're reading the requester's IP from an inbound HTTP request rather than a webhook payload, don't trust req.socket.remoteAddress behind a proxy or load balancer. Set app.set('trust proxy', true) and read req.ip, or take the left-most public address from X-Forwarded-For. And don't route anything security-sensitive on a client-supplied IP, since it's trivial to spoof.

Wire it into a helpdesk

Most teams aren't routing in a vacuum; they're routing inside Zendesk, Freshdesk, or something similar. The pattern is the same everywhere: a trigger fires on ticket creation, POSTs to your service, and your service sets the group or region on the ticket. Here's a minimal Express endpoint for the Zendesk case.

// webhook.ts - a Zendesk trigger POSTs here on ticket creation.
import express from "express";
import { routeTicket } from "./router";
import { customerLocalTime } from "./enrich";

const app = express();
app.use(express.json());

// Map your regions to real Zendesk group IDs (Admin > People > Groups).
const GROUP_IDS: Record<string, number> = { emea: 360001, amer: 360002, apac: 360003 };

app.post("/zendesk/route", async (req, res) => {
  const ticketId = req.body?.ticket?.id;
  const ip = req.body?.ticket?.requester_ip;          // include this in the trigger payload
  const langs = req.body?.ticket?.requester_langs ?? [];
  if (!ticketId) return res.status(400).json({ error: "missing ticket id" });

  const { hubId, reason } = await routeTicket(langs);
  const info = ip ? await customerLocalTime(ip) : null;

  try {
    await assignZendeskGroup(ticketId, GROUP_IDS[hubId], info);
    res.json({ ok: true, routedTo: hubId, reason });
  } catch (err) {
    console.error("assignment failed", err);        // let Zendesk's default trigger catch it
    res.status(502).json({ error: "assignment failed" });
  }
});
Enter fullscreen mode Exit fullscreen mode

The handler routes, enriches, and assigns, and if assignment fails it returns a 502 so your Zendesk fallback trigger can still catch the ticket. The assignment call itself is a plain ticket update:

async function assignZendeskGroup(ticketId: number, groupId: number, info: unknown) {
  const res = await fetch(
    `https://${process.env.ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets/${ticketId}.json`,
    {
      method: "PUT",
      signal: AbortSignal.timeout(2000),
      headers: {
        "Content-Type": "application/json",
        Authorization: `Basic ${process.env.ZENDESK_AUTH}`, // base64 of email/token:...
      },
      body: JSON.stringify({
        ticket: {
          group_id: groupId,
          comment: info
            ? { public: false, body: `Customer local time: ${JSON.stringify(info)}` }
            : undefined,
        },
      }),
    }
  );
  if (!res.ok) throw new Error(`zendesk update failed: ${res.status}`);
}
Enter fullscreen mode Exit fullscreen mode

Heads up: the trigger and webhook wiring lives in Zendesk's admin, and their payload shape and API version can shift, so check their current docs before you ship. If you can't get the end-user IP into the payload, key the enrichment off the requester's stored locale or organization region instead.

A few extra notes

Overnight coverage and on-call rotations fit the same model. An overnight hub is just a window where open is later than close, and the router handles both the start side and the early-morning carryover from the previous local day. A rotation is a hub whose hours you regenerate from your scheduling tool rather than hardcode.

The two DST changeover weekends a year are the moments to watch. Because the router reads is_dst and the local time straight from the API, it follows each region across its own switch automatically, even when the US and EU don't flip on the same date. If you were doing offset math by hand, those two weekends are exactly when you'd get paged.

IPv6 works the same as IPv4 for the enrichment call; pass the address to the ip parameter and read the same fields back. And at higher ticket volume, watch your lookup count: the per-zone cache keeps you to roughly one call per zone per minute, but if you run several instances, move that cache to Redis so they share it instead of each hammering the endpoint.

Closing

Start with two hubs and a fallback, get the open/closed check right, then add language routing and more regions once it holds. If I were building this from scratch, I'd put the fallback and the per-zone cache in on day one; both are easy to skip and annoying to add back after a 3am ticket disappears. When your volume climbs, mind the rate limits and cache accordingly.

Top comments (0)