---
title: "HolidayOps: Building a Working Days API for 250 Countries"
description: "How I built HolidayOps on my own: a Django and React platform that tells payroll, HR and logistics teams whether any date is a working day in any country, with company calendars, iCal feeds, signed webhooks and a public site that needs no JavaScript."
author: "Syed Ahmer Shah"
date: 2026-10-18
url: https://ahmershah.dev/blogs/holidayops-case-study-working-days-api
tags: ["holidayops", "case-study", "django", "react", "mysql", "api-design", "software-architecture", "system-design"]
series: "The Engineering Logs" (part 15)
---

# HolidayOps: Building a Working Days API for 250 Countries

_A case study on turning one deceptively simple question, is this a working day, into an API, a web app and a public site that never disagree with each other_

![Syed Ahmer Shah in a HolidayOps hoodie beside an October 2026 calendar and a glowing world map, under the title HolidayOps: working days API for 250 countries](https://ahmershah.dev/blog/holidayops-case-study-working-days-api/cover-efb60a08.webp)


Ask anyone who runs payroll for a team spread across countries which week of the month they dread, and it's usually the one with a public holiday in it. Not because of the holiday. Because of the spreadsheet: a tab per country, holidays typed in from government websites, a note in red for the year a weekend moved. Every pay run starts with the same question asked three times over, which of these days actually count, and when the spreadsheet is wrong, someone gets paid late.

HolidayOps is my answer to that question. You give it a date and a country, or a company calendar that merges several countries with your own closures, and it tells you whether that date is a working day and why. It does that through a REST API, a web app, private calendar feeds, a monthly email and signed webhooks, for 250 countries and 624 regions, in English, Urdu and Arabic. I built all of it myself in October 2026, across 43 pull requests.

This is how it fits together, the parts that were harder than they looked, and what I would do differently.

* * *

## The question that sounds easy

"Is the 2nd of April a working day?" has at least five honest answers depending on where you stand.

In London it's a Friday, so yes. In Riyadh it's a Friday too, which is the weekend, so no. In some years it falls inside a holiday that follows the moon, so the answer depends on an astronomical sighting that hasn't happened yet. If your company closes for its annual retreat that week, the government calendar is right and you're still not working. And if it's 2 a.m. in Dubai but still the 1st in New York, "today" isn't even the same date for your two offices.

Most tools pick one of those answers and quietly assume the rest. That assumption is where the late payments come from.

So the brief I wrote for myself was one sentence: **every surface of the product gives the same answer to the same question, and that answer accounts for the country's own weekend, its moved working days, its holidays and the company's own days off.**

![The who is off today page: countries on a public holiday, on their weekend and at work, each on its own local date](/blog/holidayops-case-study-working-days-api/e2c979e1e2.webp)

## One engine, five ways out

The decision that shaped everything else was where the date logic lives.

It lives in one place: a small package called `apps/core`, written in plain Python with no Django imports at all. Views, serializers, the feed generator, the digest task and the public pages all call into it. None of them do date maths of their own. The package has 100% branch coverage, and CI refuses a pull request that drops it.

That sounds like ceremony until you remember how many ways out the product has. An API response, a calendar feed in someone's Outlook, a webhook to their payroll system, a monthly email and a public page that search engines index. If any two of those computed "working day" separately, they would eventually disagree, and the customer would be right to stop trusting all of them.

The engine itself is deliberately thin. Holiday data comes from [holidays](https://github.com/vacanza/holidays), an open-source library maintained by a large community and updated several times a year. I made a rule early on: use the library's own logic for weekends, moved working days and counting, never reimplement it. When I'm tempted to write my own "next working day" loop, the library already has one, tested against far more countries than I will ever check by hand.

## The weekend is data

The first bug I found was in my own head. I had treated "the weekend" as a property of a country.

It isn't. It's a property of a country *on a date*. The United Arab Emirates moved from a Friday-Saturday weekend to Saturday-Sunday at the start of 2022. A system that reads one weekend per country gets every date before that change wrong, and payroll is exactly the kind of work that looks back at old dates.

So the engine asks the library for the weekend per date, not per country. On the landing page I turned that into a small table that I still enjoy looking at: of the 250 countries, 222 rest on Saturday and Sunday, 18 on Friday and Saturday, 8 on Sunday only, one on Friday alone and one, Brunei, on Friday and Sunday with a working Saturday in between.

## Company calendars

A government calendar is the floor, not the answer. Real companies have offices in several countries, close for a week in December, run half days before a festival and sometimes work a Saturday to finish a quarter.

A company calendar in HolidayOps merges any number of countries and regions, then layers the company's own days on top: closures, half days and extra working days. Custom days always win over the countries underneath. Half days count as working days in the maths, because that's how payroll treats them, but they're flagged in every answer so a scheduler can still avoid them.

Merging had one trap. The library's combined calendar quietly falls back to a Saturday-Sunday weekend, which would turn a Riyadh office's Friday into a working day. The merged calendar now pins its weekend rule explicitly: the first country's, or one the company sets itself.

![The working-day overlap tool comparing Saudi Arabia, the United Kingdom and the UAE for one month](/blog/holidayops-case-study-working-days-api/da05a1dd6e.webp)

## Getting the answer out

The API is the product's spine. It's versioned under `/api/v1/`, described by an OpenAPI schema, and every change to it regenerates both the schema and the typed client the web app uses, in the same pull request. That habit paid for itself when I generated Python and TypeScript SDKs straight from the public part of that schema.

API keys look like `hops_<prefix>_<secret>`. Only a hash of the secret is stored, the full key is shown once, and each key gets the per-minute rate and monthly quota of its organization's plan. Anonymous calls are allowed too, at a lower per-IP rate, because the fastest way to understand an API is to call it without signing up for anything.

Not everyone wants an API. Some people want the holidays in the calendar they already live in, so every company calendar can publish a private iCal feed with a revocable token. Writing that feed taught me something I didn't expect: the iCal format folds lines at 75 *octets*, not characters. My first version counted characters, and every Urdu and Arabic holiday name arrived in Google Calendar with a broken letter in the middle. Folding by UTF-8 bytes, without ever splitting a character, fixed it.

For systems that need to react, there are signed webhooks with retries and a delivery log, and for people who just want a heads-up there's a monthly email of the next 30 days of holidays, written in their own language.

## A public site with no JavaScript

The public site is server-rendered Django templates and one stylesheet. There is no JavaScript on it at all, and the Content Security Policy says so: `script-src 'none'`.

That constraint made the design work harder and better. The landing page shows how many countries are on a public holiday right now, how many are on their weekend and how many are at work, each country on its own local date. It shows a month calendar that turns over on its own, the next days off around the world as a row of ticket stubs, and an "is it a working day?" demo that is just a form answered by the server. Motion is CSS only and switches off for people who ask their device for less of it.

![The live section of the landing page: five countries on a public holiday, 206 on their weekend and 39 at work, with flag chips](/blog/holidayops-case-study-working-days-api/b09c8e08f3.webp)

"Right now" turned out to be its own small project. Each country is read in its own time zone from CLDR data. CLDR lists a country's zones alphabetically, which makes the United States start in Adak, Alaska, so countries spanning several zones use their capital's instead. Pages are cached per theme and language with the hour in the cache key, because somewhere in the world a new day starts every hour.

Every country also gets a page per year with a full-year calendar, a pattern drawn from its flag's colours, and a "today" page with this week at a glance and a widget other sites can embed with one line of HTML.

![Saudi Arabia's today page: a large verdict, the next seven days and an embeddable widget](/blog/holidayops-case-study-working-days-api/14d04eb007.webp)

## Security that doesn't depend on me remembering

A product that holds companies' calendars and API keys needs security that holds even on the days I'm tired.

**Tenant isolation is tested, not trusted.** Every query on organization data is filtered by the current organization through one mixin, and a test suite tries to reach every endpoint from the wrong organization and expects a 404. Never a 403, because a 403 confirms the thing exists.

**Limits can't be raced.** Invites, webhooks, API keys and feed links all have caps. Counting and creating in two steps lets two parallel requests both pass the count, so each check runs under a MySQL named lock per owner. I chose a named lock over locking the organization's row because InnoDB takes a shared lock on the parent for every child insert, and locking the row would have stalled usage records for the whole organization while an invite email was being sent.

**Webhooks can't be pointed inward.** A webhook URL is someone else's server, and DNS can change between the moment you check an address and the moment you connect to it. The delivery code resolves the host, refuses anything that isn't a public address, then connects to that exact address while still presenting the original host name for the certificate check.

**Sign-in has no weak door.** Passwords are Argon2. Reset codes are six random digits, stored only as a keyed hash, valid for ten minutes and once. Two-factor codes follow RFC 6238, and the last accepted time step is stored so a code can't be replayed. Signing in with Google or Microsoft never skips the account's own second factor, and Microsoft sign-in never links an existing account by email, because Microsoft's email claim is whatever a tenant admin typed into a field.

## What I got wrong

**I trusted my own cache.** The landing page cached for six hours, so just after midnight it could keep showing yesterday as "today". The fix was one line, the hour in the cache key, but I only found it by asking what the page would show at 00:05, a question I should have asked on day one.

**I named a class `.today` twice.** The live section and a calendar cell both used it, and for one screenshot the cell grew to the size of a section. The section is called `.now` now, and there's a note in the project's status file so the next person doesn't do the same thing.

**I underestimated translations.** Three languages triples every string, and gettext sometimes matches a new English string to an unrelated old translation. "Questions" once came out as the Arabic word for "regions". CI now fails on any fuzzy match, and every Urdu and Arabic string is marked unverified until a native speaker signs it off.

## By the numbers

- 250 countries and 624 regions, each with its own weekend
- 481 automated tests: 447 on the backend, 34 on the frontend
- 43 pull requests, every one checked by CI and CodeQL before it merged
- 3 languages, two of them right to left
- 0 lines of JavaScript on the public site

## What's next

The code is done. What's left is the work that happens outside the repository: hosting, a native speaker reading every Urdu and Arabic sentence, a legal review of the privacy policy and terms, and the first real customer telling me what I got wrong.

If I started again, I would build the "today" logic before the landing page instead of after it. Almost every hard bug in this project came back to one question I had assumed was simple: what date is it, and for whom?

The source is on [GitHub](https://github.com/ahmershahdev/holidayops), and I wrote up the date problem itself in [The weekend is not a constant](/blogs/the-weekend-is-not-a-constant).


---

*Originally published at https://ahmershah.dev/blogs/holidayops-case-study-working-days-api — © Syed Ahmer Shah*
