A breach timer on working days is wrong in both directions unless the contract names the calendar, the first day of the count and the time zone. Narwhal supplies the public holidays of a country and year, and the desk's own code and contract supply every other rule.
Start the timer from a calendar the contract can name
A timer that runs through a public holiday shows a breach the contract never allowed. A timer that stops on a day the contract does not exclude hides a real one. The fix is a calendar that both sides can point to. This request reads the public holidays of Singapore for 2026 and keeps the two entries around Deepavali.
curl -s -H "Authorization: Bearer $NARWHAL_API_KEY" \
"https://api.narwhalapi.com/v1/calendars/holidays/SGP/2026"{
"country": "SGP",
"coverage": [
{
"scope": "national_public",
"subdivision_codes": []
}
],
"holidays": [
{
"date": "2026-11-08",
"name": "Deepavali",
"holiday_key": "deepavali",
"scope": "national_public",
"subdivision_codes": []
},
{
"date": "2026-11-09",
"name": "Deepavali (Observed)",
"holiday_key": "deepavali",
"scope": "national_public",
"subdivision_codes": [],
"observed": true
}
]
}Each entry has a date, a name, a scope and subdivision_codes. Deepavali is dated Sunday 8 November. The entry marked observed: true is Monday 9 November, the substitute day, and it sits on a weekday. The coverage list names the scopes the calendar holds, which is national_public for Singapore. The API supplies holidays only, so the rest of the clock is a contract matter.
What the contract must define before the code runs
The code needs an answer to each row of the table. Where the contract is silent, the desk and the customer will find out at the first disputed ticket.
| Setting | Question for the contract | Who supplies it |
|---|---|---|
| Calendar | Which country, and which holiday scope? | Contract, then the API's scope filter |
| Counted days | Are Saturdays and Sundays excluded? | Contract |
| First day | Is the opening day day 0 or day 1? | Contract |
| Deadline | Is the due date the end of that day? | Contract |
| Time zone | Whose local date starts the clock? | Contract |
| Paused time | Does the clock stop while the customer replies? | Contract and the ticket system |
| Hours in a day | Does a working day mean office hours? | Contract and the support schedule |
A written answer to each row turns the timer into a function with no judgement left in it. The API settles the calendar row and nothing else.
Compute the due date and test for a breach
The script collects the holiday dates into a set and treats a working day as a weekday outside the set. The opening day is day 0, so counting starts the next day. The first block converts a ticket timestamp into the date in the contract's time zone. The second block tests a resolved ticket.
import json, os, urllib.request
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
def get(path):
req = urllib.request.Request("https://api.narwhalapi.com" + path,
headers={"Authorization": "Bearer " + os.environ["NARWHAL_API_KEY"]})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def holiday_dates(country, years):
return {date.fromisoformat(h["date"]) for y in years for h in get(f"/v1/calendars/holidays/{country}/{y}")["holidays"]}
def is_working_day(d, off):
return d.weekday() < 5 and d not in off
def due_date(opened, working_days, off):
d, left = opened, working_days
while left:
d += timedelta(1)
left -= is_working_day(d, off)
return d
def elapsed_working_days(opened, resolved, off):
return sum(is_working_day(opened + timedelta(n), off) for n in range(1, (resolved - opened).days + 1))
off = holiday_dates("SGP", (2026,))
sg = ZoneInfo("Asia/Singapore")
# Clock start: the ticket timestamp becomes a date in the contract's time zone.
created = datetime(2026, 11, 5, 20, 0, tzinfo=ZoneInfo("America/New_York"))
opened = created.astimezone(sg).date()
print("created", created.date(), "in New York is", opened, "in Singapore")
print("3 working days from the Singapore date:", due_date(opened, 3, off))
print("3 working days from the New York date: ", due_date(created.date(), 3, off))
# Breach test: compare the resolution date with the due date.
opened, resolved = date(2026, 2, 13), date(2026, 2, 27)
due = due_date(opened, 5, off)
late = elapsed_working_days(opened, resolved, off) - 5
print("5 working days from", opened, "is due", due, "; resolved", resolved, "so", late, "working days late")
created 2026-11-05 in New York is 2026-11-06 in Singapore
3 working days from the Singapore date: 2026-11-12
3 working days from the New York date: 2026-11-11
5 working days from 2026-02-13 is due 2026-02-24 ; resolved 2026-02-27 so 3 working days lateThe New York ticket is created on Thursday 5 November at 20:00, which is already Friday 6 November in Singapore. Three working days from the Singapore date end on Thursday 12 November, because Monday 9 November is the observed Deepavali holiday. Counted from the New York date, the same ticket would be due Wednesday 11 November, a day earlier, so the time zone row decides whether the desk breaches. The second ticket opened on Friday 13 February is due Tuesday 24 February, because Chinese New Year takes Tuesday 17 and Wednesday 18 February. Resolved on Friday 27 February, it is 3 working days late.
How does the timer treat a holiday on a weekend?
The weekday test removes the day first, so it is never subtracted twice. Hari Raya Puasa is dated Saturday 21 March 2026 in the Singapore response, and Deepavali is dated Sunday 8 November. Neither changes a count. The substitute day is its own entry with observed: true, so the clock removes it without extra code.
Which scope should an SLA use?
Use the scope that matches the calendar the customer's business follows. Singapore returns national_public alone. Indonesia returns national_public and collective_leave, and Australia returns regional entries with subdivision_codes, plus a bank scope. The year endpoint takes an optional scope filter. Pass it when the SLA excludes one kind of day, and use the same scope on every request so the count can be repeated.
Can the API count business hours?
No. The calendar endpoints return holiday dates. Opening hours, lunch breaks and a pause while the customer replies come from the support schedule and the ticket system. For an SLA written in hours, take the working days from this code and apply the schedule to the first and last day.
How do I keep requests down with many open tickets?
Fetch each country and year once and keep the set of dates in memory or a table of your own. Every ticket for that country and year reuses the set. Call the year endpoint again only for a ticket that reaches into a year you have not loaded. The guide on counting business days between two dates shows the same arithmetic with a spreadsheet option.
Published .