T
Telephony SOPKnowledge Base
Search
← All topics

Business Hours Logic — Time-of-Day, Day-of-Week, and Holiday Routing

runnable

Route calls differently based on time-of-day, day-of-week, holidays, and timezone. SignalWire SWML uses `cond` blocks with date/time variables, or fetches a schedule from an external API. Handle DST correctly with IANA timezone names, treat 5 PM EST as 4 PM EST during DST. Holidays require an external calendar source — hardcoding is brittle.

business-hourstime-routingswmlschedulingafter-hoursholiday
Agent trigger phrases: business hours routing · after hours call flow · time of day call routing · open closed hours SWML · holiday call routing · timezone call flow

Business Hours Logic

Route calls differently depending on the time. The same DID rings sales during business hours, voicemail after hours, an answering service on weekends, and a holiday message on December 25th. This pattern is one of the most common requests in call flow design and one of the easiest to get wrong because of timezones and DST.

The four time dimensions

  1. Time of day — 9 AM to 5 PM is business hours, otherwise after-hours.
  2. Day of week — Mon-Fri vs Sat-Sun.
  3. Date — specific dates like 2026-12-25 (Christmas) or 2026-07-04.
  4. Timezone — the customer's local timezone, which shifts the wall-clock by 1 hour during DST in many zones.

Mishandling any one creates "the phone says we're closed when we're open" bugs that are infuriating to the customer and trust-destroying.

Always use IANA timezone names

Wrong: EST, PST, Eastern, +0500

Right: America/New_York, America/Los_Angeles, America/Chicago, America/Denver, America/Phoenix

IANA names encode the timezone's DST rules. America/New_York is EST (UTC-5) in winter and EDT (UTC-4) in summer. America/Phoenix is MST (UTC-7) year-round because Arizona doesn't observe DST.

Hardcoding EST or +05:00 will be wrong six months a year.

SWML — basic open/closed branching

version: 1.0.0
sections:
  main:
    - answer: {}
    - cond:
        when: "hour(now('America/New_York')) >= 9 && hour(now('America/New_York')) < 17 && weekday(now('America/New_York')) >= 1 && weekday(now('America/New_York')) <= 5"
        then:
          - connect:
              to: +12125551111
              timeout: 30
        else:
          - play: say:We're closed right now. Please leave a message.
          - record_call: {}

weekday() returns 0 (Sunday) through 6 (Saturday). Mon-Fri = 1-5. hour() returns 0-23.

SWML — fetch schedule from external API

For complex schedules (holidays, varied hours, multiple offices), keep the schedule in an external service:

- request:
    url: https://your.api/business-hours?location=denver
    method: GET
    save: schedule
- cond:
    when: "${schedule.is_open}"
    then:
      - connect:
          to: "${schedule.live_number}"
    else:
      - play: say:${schedule.closed_message}
      - record_call: {}

The external API returns:

{
  "is_open": true,
  "live_number": "+13035551111",
  "closed_message": "We're closed. Office reopens at 9 AM Mountain time."
}

This lets non-developers update hours in a dashboard without redeploying SWML.

Holiday calendar

Hardcoding holidays inline is brittle — easy to miss MLK Day, Juneteenth, Indigenous Peoples Day depending on jurisdiction. Use a library or external data source.

Pattern: store the holiday calendar as a JSON file or Supabase table:

{
  "2026-01-01": "New Year's Day",
  "2026-01-19": "Martin Luther King Jr. Day",
  "2026-02-16": "Presidents' Day",
  "2026-05-25": "Memorial Day",
  "2026-06-19": "Juneteenth",
  "2026-07-04": "Independence Day",
  "2026-09-07": "Labor Day",
  "2026-11-26": "Thanksgiving",
  "2026-12-25": "Christmas"
}

Fetch this from the SWML script:

- request:
    url: https://your.api/holiday-check?date=${call.created_at}&timezone=America/New_York
    method: GET
    save: holiday
- cond:
    when: "${holiday.is_holiday}"
    then:
      - play: say:Today is ${holiday.name}. Our office is closed.
      - record_call: {}
    else:
      # Fall through to normal hours logic
      - cond:
          when: "..."

Pattern: caller's local timezone

If the caller's geographic location matters (e.g., "we're open 9 AM caller time"), derive the timezone from the caller's area code or use a phone-number-to-timezone API:

- request:
    url: https://your.api/caller-timezone?phone=${call.from}
    method: GET
    save: tz
- request:
    url: https://your.api/business-hours?caller_tz=${tz.zone}
    method: GET
    save: schedule

Area code is an imperfect signal — mobile users keep area codes after moving. For better accuracy, ask the caller for their location early in the call or fall back to the office's local timezone.

DST transitions — the gotchas

In America/New_York:

  • Spring forward: 2026-03-08 at 2 AM, clock jumps to 3 AM. The 2:30 AM hour doesn't exist.
  • Fall back: 2026-11-01 at 2 AM, clock falls to 1 AM. The 1:30 AM hour happens twice.

Don't schedule call flow transitions during these windows. If a callback is scheduled for 2:30 AM on a DST transition day, it should fire at either 1:30 AM or 3:30 AM (your choice) — using a proper datetime library that handles ambiguous times is essential.

In application code, store all timestamps in UTC and convert to local time only for display. SWML's now('America/New_York') handles DST automatically; raw date math with offsets does not.

Pattern: lunch break

Some offices close for lunch 12-1 PM. Avoid sending callers to voicemail during lunch — let them stay in queue:

- cond:
    when: "hour(now('America/Chicago')) == 12"
    then:
      - play: say:Our team is on lunch. Please hold, we'll be back in a moment.
      - connect:
          to: queue:lunch_hold
    else:
      # Normal hours branch

A queue with hold music feels less abandoning than voicemail for a known-short interval.

Pattern: emergency after-hours number

For medical, legal, plumbing, locksmiths — after-hours callers may need a different number:

- cond:
    when: "is_business_hours()"
    then:
      - connect:
          to: +13035551111
    else:
      - prompt:
          play: say:Office is closed. Press 1 for emergencies, 2 to leave a message.
          max_digits: 1
      - cond:
          when: "${prompt_value} == '1'"
          then:
            - connect:
                to: +13035559999  # on-call emergency line
          else:
            - record_call: {}

Pattern: multi-office routing

Different offices in different timezones — route to whichever is currently open:

- cond:
    when: "hour(now('America/Los_Angeles')) >= 9 && hour(now('America/Los_Angeles')) < 17"
    then:
      - connect:
          to: +14155551111  # LA office
    else:
      - cond:
          when: "hour(now('America/New_York')) >= 9 && hour(now('America/New_York')) < 17"
          then:
            - connect:
                to: +12125551111  # NY office
          else:
            - cond:
                when: "hour(now('Asia/Tokyo')) >= 9 && hour(now('Asia/Tokyo')) < 17"
                then:
                  - connect:
                      to: +81335551111  # Tokyo office
                else:
                  - play: say:All offices are closed. Please leave a message.
                  - record_call: {}

This is a "follow the sun" support routing model.

Pattern: scheduled message variations

Different greetings for different times of day:

- cond:
    when: "hour(now('America/New_York')) < 12"
    then:
      - play: say:Good morning! Thanks for calling.
- cond:
    when: "hour(now('America/New_York')) >= 12 && hour(now('America/New_York')) < 17"
    then:
      - play: say:Good afternoon! Thanks for calling.
- cond:
    when: "hour(now('America/New_York')) >= 17"
    then:
      - play: say:Good evening! Thanks for calling.

Compatibility API (LaML)

LaML doesn't have inline date functions. Pattern is to call out to your own endpoint that returns LaML based on current time:

@app.route("/incoming-call", methods=["POST"])
def incoming_call():
    now = datetime.now(ZoneInfo("America/New_York"))
    is_open = now.weekday() < 5 and 9 <= now.hour < 17
    is_holiday = check_holiday(now.date())
    
    if is_open and not is_holiday:
        return Response("""
            <Response>
                <Dial>+12125551111</Dial>
            </Response>
        """, mimetype="text/xml")
    else:
        return Response("""
            <Response>
                <Say>We're closed. Please leave a message.</Say>
                <Record />
            </Response>
        """, mimetype="text/xml")

Testing business hours logic

Common pre-prod test cases:

| Test case | Expected behavior | |---|---| | Tuesday 10 AM ET | Open → sales | | Saturday 10 AM ET | Closed → voicemail | | Tuesday 8:59 AM ET | Closed (1 minute before open) | | Tuesday 9:00 AM ET | Open | | Tuesday 4:59 PM ET | Open | | Tuesday 5:00 PM ET | Closed (exact boundary) | | December 25, any time | Holiday closed | | DST transition Sunday 2:30 AM | Should not error |

Use a test harness that lets you spoof "now" — your business-hours function should take a now parameter instead of calling datetime.now() directly, for testability.

Common pitfalls

  • Hardcoding offsets instead of IANA names — wrong six months a year due to DST.
  • Computing hours in server timezone — server is UTC, office is local. Always pass timezone explicitly.
  • Missing holidays — federal vs state holidays vary, religious vs secular vary, recent additions (Juneteenth) get missed.
  • No fallback when external schedule API is down — always have a default (typically "closed" with a polite message and a callback).
  • Exact-minute boundaries — 5:00:00 PM is the last second of open hours; 5:00:01 should already be closed. Test both sides.
  • Forgetting the lunch hour — if hours are 9-12 and 1-5, the 12-1 branch needs explicit handling.

Related patterns

References

  • IANA Time Zone Database — official timezone names
  • ICU CLDR — common locale data including timezone aliases
  • RFC 5545 — iCalendar specification (for parsing external holiday calendars)