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
- Time of day — 9 AM to 5 PM is business hours, otherwise after-hours.
- Day of week — Mon-Fri vs Sat-Sun.
- Date — specific dates like 2026-12-25 (Christmas) or 2026-07-04.
- 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
- Call routing strategies — round-robin, skill-based, time-of-day routing
- Missed call workflows — what happens after-hours
- Callback scheduling — let after-hours callers schedule a callback
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)