Skip to content

Latest commit

 

History

14 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Cambodia Public Holiday API

License: MIT Go

A free, open-source API for Cambodian public holidays (ថ្ងៃឈប់សម្រាកកម្ពុជា), filterable by day, month and year — backed by SQLite and a scraper that refreshes each year from official and semi-official sources.

Single static binary, no cgo, no database server, no API keys.

Hosted instance: https://khmerholiday.layhak.dev (not yet live — until then, run it locally with the quick start below)

curl 'https://khmerholiday.layhak.dev/api/v1/holidays?year=2026&month=4'

Why confidence levels exist

Cambodian public holidays are not fixed in law year to year. Each year the Royal Government issues a sub-decree (អនុក្រឹត្យ) — normally signed around September of the preceding year — and the Ministry of Labour publishes a matching Prakas that binds employers. 2026's schedule was signed on 5 September 2025 and published as Prakas No. 216/25.

Several holidays follow the Khmer lunisolar calendar and move every year:

  • Pchum Ben (ភ្ជុំបិណ្ឌ)
  • Water Festival (បុណ្យអុំទូក)
  • Royal Ploughing Ceremony (ច្រត់ព្រះនង្គ័ល)
  • Meak Bochea, Visak Bochea

Until the sub-decree exists, any date for those is a projection. Third-party APIs happily serve them years ahead without saying so — which is how a wrong date ends up in a payroll run.

So every record here carries a confidence:

Level Meaning
official Verified against the signed sub-decree / Prakas.
reported The state news agency's announced day count corroborates our dates.
computed Projected from the lunisolar calendar. May shift.

Filter them out with ?official=true, or read the warnings array in every response.


Quick start

Requires Go 1.26+. No database server, no cgo, no API keys. Redis is optional; without it the API reads directly from SQLite.

git clone https://github.com/Layhak/khmer-holiday-api.git
cd khmer-holiday-api

make build           # -> bin/khapi, bin/khapi-scrape
make seed            # fetch 2024-2027 into data/holidays.db
make run             # serve on :8080, docs at http://localhost:8080/

The repository ships no holiday datadata/holidays.db is generated by make seed, so the dates you serve are always ones you fetched and can audit.

curl 'http://localhost:8080/api/v1/holidays?year=2026'
curl 'http://localhost:8080/api/v1/holidays?year=2026&month=4'
curl 'http://localhost:8080/api/v1/holidays?year=2026&month=4&day=14'
curl 'http://localhost:8080/api/v1/holidays/2026-04-14'

API

Route Purpose
GET /api/v1/holidays List and filter
GET /api/v1/holidays/{YYYY-MM-DD} Is this date a holiday?
GET /api/v1/years Years held in the database
GET /api/v1/status Coverage + per-source scrape audit
GET /api/v1/sources Upstream sources and their live status
GET /healthz Liveness
GET / Self-hosted HTML docs

Filters

All combine with AND.

Param Example Meaning
year 2026 Calendar year
month 4 Month, 1–12
day 14 Day of month, 1–31
from, to 2026-01-01 Inclusive date range
key pchum_ben One holiday series
official true Only decree-confirmed dates

?month=4&day=14 with no year returns 14 April across every stored year. A syntactically valid but unknown key returns 404 with {"error":"holiday key \"...\" does not exist","status":404}. A known key that has no rows under the other supplied filters still returns an empty successful result.

Response

{
  "count": 3,
  "filter": { "year": 2026, "month": 4 },
  "holidays": [
    {
      "key": "khmer_new_year",
      "name_en": "Khmer New Year",
      "name_km": "ពិធីបុណ្យចូលឆ្នាំថ្មីប្រពៃណីជាតិ",
      "ordinal": 1, "of_days": 3,
      "is_lunar": false,
      "confidence": "official",
      "source": "nbc",
      "source_url": "https://www.nbc.gov.kh/english/news_and_events/official_holiday.php",
      "decree": "Prakas No. 216/25",
      "date": "2026-04-14",
      "year": 2026, "month": 4, "day": 14,
      "weekday": "Tuesday"
    }
  ]
}

Multi-day festivals are stored one row per day (so a date lookup is a plain equality match) with ordinal/of_days giving the position — "day 2 of 3".

CORS, caching and rate limits

  • CORS is open (Access-Control-Allow-Origin: *) — call it directly from a browser, no proxy needed. Retry-After, X-RateLimit-Limit, and X-Cache are exposed to browser JavaScript.
  • Caching: holiday responses send Cache-Control: public, max-age=3600. When KHAPI_REDIS_URL is set, successful JSON responses are also cached in Redis for five minutes and report X-Cache: MISS or HIT. Redis errors fail open to SQLite as X-Cache: BYPASS. Errors, oversized responses, undocumented or duplicated query parameters, /api/v1/status, and /healthz are never stored in Redis.
  • Rate limit: 60 requests/minute per IP by default, burst 20. Exceeding it returns 429 with a five-second Retry-After. Requests made during that cooldown double the same IP's delay up to 15 minutes, so sustained spam keeps extending its own block. Ten quiet minutes reset the penalty. /healthz is never limited.

Please cache responses on your side — the data changes a few times a year.

Env var Default Purpose
KHAPI_RATE_LIMIT 60 Requests/minute per IP. 0 disables.
KHAPI_RATE_BURST 20 Largest momentary burst.
KHAPI_RATE_PENALTY_SECONDS 5 Initial cooldown after exhausting the per-IP bucket.
KHAPI_RATE_PENALTY_MAX_SECONDS 900 Maximum cooldown for an IP that ignores Retry-After.
KHAPI_CACHE_SECONDS 3600 Cache-Control max-age.
KHAPI_REDIS_URL empty Optional redis:// or rediss:// connection URL.
KHAPI_REDIS_CACHE_SECONDS 300 Server-side Redis response TTL; 0 disables.
KHAPI_TRUST_PROXY false Read client IP from CF-Connecting-IP / X-Forwarded-For.
KHAPI_ADDR :8080 Listen address.
KHAPI_DB data/holidays.db Database path.

⚠️ Set KHAPI_TRUST_PROXY=true only when the service actually sits behind a proxy or CDN. Those headers are client-supplied; trusting them on a directly exposed service lets anyone spoof an IP and bypass the rate limit entirely. The proxy must overwrite, not append to, client-supplied forwarding headers.

Security boundaries

  • Scraper responses and external helper output are capped at 16 MiB; oversized content is rejected instead of being silently truncated or buffered without limit.
  • Every parsed snapshot is validated before reconciliation: requested year, source identity, confidence ceiling, duplicate dates, row count, metadata length, and provenance URL scheme.
  • -replace requires a validated complete-year source and runs as one SQLite transaction. It refuses a known official day-count mismatch and cannot be combined with a single partial -source.
  • Public errors are generic, failed helper details stay out of /api/v1/status, and only successful API responses receive public cache headers.
  • Redis keys are fixed-length SHA-256 hashes of normalized request targets; only successful JSON responses up to 2 MiB are stored with a mandatory TTL, and Redis outages fall back to SQLite. The production bootstrap binds Redis to loopback, enables protected mode, and caps it at 64 MiB with LRU eviction.
  • The server bounds request headers and read/write time, validates forwarded IP addresses, caps in-memory rate-limit identities, and sends restrictive browser security headers.

Sources

Verified live. make status shows the current state of each.

Source Authority Status Role
Tallyfy computed ✅ working Public JSON calendar covering 2026–2030. Lowest-precedence future-year cross-check; bank-only closures are excluded and it never authorizes destructive replacement.
date.nager.at computed ✅ working Primary dates. Free JSON, no API key, EN + Khmer names, publishes future years.
Wikipedia computed ✅ working Fixed-date cross-check via the MediaWiki API. Emits no lunar dates by design — the article gives them as "Moveable, April or May".
AKP reported ✅ working State news agency. Announces the sub-decree and its total day count, used to corroborate.
MLVT official ⚠️ evidence only Ministry of Labour publishes the binding Prakas. The PDF is a scanned image with no text layer — the adapter recovers the decree number and PDF URL, but dates need OCR or a human.
Verified MLVT archive official ✅ working Past calendars transcribed and visually checked against their signed MLVT Prakas. The 2025 archive contains all 22 days in Prakas No. 218/24, including Visak Bochea on 11 May.
National Bank of Cambodia official ✅ working Official current-year calendar with machine-readable dates. For 2026 it lists the same 21 days announced by AKP and governed by Prakas No. 216/25.
MEF official ❌ blocked Returns HTTP 403 to every non-browser client (Cloudflare). See below.

On mef.gov.kh

You asked for this one specifically. It does not work for automated scraping: both https://mef.gov.kh/ and its document-category paths return 403 to a plain HTTP client, including with a realistic browser User-Agent. It needs a real browser engine.

The adapter is kept in the registry anyway so that:

  1. Every run records the block in the audit table — make status tells you if the site ever opens up, instead of someone having to remember to re-test it.
  2. It starts working with no code change if the protection is dropped.
  3. You can bypass it today with a headless fetcher:
export MEF_FETCH_CMD="curl-impersonate-chrome -sL"
bin/khapi-scrape scrape -year 2027 -source mef

MEF_FETCH_CMD is any command that takes a URL as its last argument and prints HTML to stdout. It is executed directly, never through a shell.


How reconciliation works

Sources are merged per year, weakest authority first, so stronger data lands last:

  1. Dates come only from sources that carry dates. On conflict, higher authority wins; equal authority is broken by an explicit precedence list (Nager beats Wikipedia, which beats Tallyfy). All dates for one canonical holiday come from a single winning source, so conflicting calendars cannot be accidentally combined into extra days.
  2. Evidence-only snapshots (AKP's day count, MLVT's Prakas link) contribute no dates but supply the decree reference and expected total.
  3. NBC's machine-readable government calendar contributes official dates for the current published year; visually verified MLVT calendars do the same for supported past years.
  4. If an official source announced N days and we hold exactly N, the dataset is corroborated and computed rows are promoted to reported.
  5. If the counts disagree, nothing is promoted and a warning is emitted. A silent mismatch is the failure mode that puts a wrong date in production, so it is surfaced loudly.

The store enforces this independently: a write is rejected if it would lower a row's confidence. A computed projection can never overwrite a verified date, regardless of the order scrapes run in.

Promoting to official

When NBC publishes the requested year, its official HTML calendar is imported directly. Past years can be added to the verified MLVT archive after checking the scanned governing document. For years in neither source, the final promotion remains a deliberate human step:

bin/khapi-scrape status                  # which years are provisional?
bin/khapi-scrape scrape -year 2027       # prints the Prakas PDF URL it found
# ...open the PDF, check the dates...
bin/khapi-scrape verify -year 2027 \
    -decree "Prakas No. NNN/26" \
    -url    https://mlvt.gov.kh/media/k2/attachments/YYYYMMDD_NNN.pdf

If the sub-decree moved a date away from the projection, re-scrape with -replace so the stale row is removed rather than orphaned. Replacement requires the normal multi-source run; -replace -source ... is deliberately rejected because a partial source must never erase the full year.


CLI

khapi-scrape scrape  [-year N] [-years A-B] [-source NAME] [-replace] [-db PATH]
khapi-scrape status  [-db PATH]
khapi-scrape verify  -year N -decree "..." [-url URL] [-db PATH]
khapi-scrape sources

With no -year/-years, scrape fetches the current and next year — the pair a cron job cares about.


Scheduling

See deploy/crontab.example. The schedule watches weekly from July onward, since the sub-decree lands around September:

# Every Monday 06:00, July-December: has next year's sub-decree appeared?
0 6 * 7-12 1  cd $APP_DIR && ./bin/khapi-scrape scrape -year $(date +\%Y -d '+1 year')

# Monthly: re-verify the current year (catches mid-year amendments)
30 6 1 * *    cd $APP_DIR && ./bin/khapi-scrape scrape -year $(date +\%Y)

Docker

docker build -t khmer-holiday-api .
docker volume create khapi-data

docker run --rm -v khapi-data:/data --entrypoint khapi-scrape \
    khmer-holiday-api scrape -years 2024-2027

docker run -d -p 8080:8080 -v khapi-data:/data --name khapi khmer-holiday-api

GitHub Actions deployment

.github/workflows/ci-deploy.yml runs formatting, vet, race-enabled tests, and builds on every pull request and push to main. Production deployment is deliberately gated until the server is bootstrapped.

Configure these GitHub Actions secrets:

Name Purpose
DEPLOY_HOST Droplet public IPv4 address
DEPLOY_USER Restricted SSH user, normally deployer
DEPLOY_SSH_KEY Private Ed25519 deployment key
DEPLOY_KNOWN_HOSTS Pinned SSH host-key line for the Droplet

Then set the repository variable DEPLOY_ENABLED=true. A push to main or a manual workflow run will build Linux binaries, verify the archive checksum, deploy through the restricted remote installer, restart the systemd service, roll back on a failed local health check, and verify the public HTTPS endpoint.

Server configuration lives in deploy/:

  • bootstrap-server.sh creates the service/deployment users, installs Caddy and Redis, disables password SSH, and enables the firewall.
  • remote-deploy.sh performs checksum-verified atomic releases and rollback.
  • khapi.service runs the API as an unprivileged, sandboxed systemd service.
  • khapi-scrape.timer refreshes the current and next year every Monday.
  • Caddyfile terminates HTTPS for khmerholiday.layhak.dev.

Layout

cmd/api/            HTTP server
cmd/scrape/         scrape / status / verify CLI
internal/model/     Holiday, Snapshot, Confidence
internal/store/     SQLite schema, filtering, confidence-aware upsert
internal/rediscache/ Redis cache-aside adapter
internal/sources/   one adapter per upstream + the reconciler
internal/httpx/     shared retrying HTTP client
internal/api/       routes, JSON encoding, self-hosted docs
deploy/             crontab example

Development

make check     # fmt + vet + test
make test

Tests cover the reconciler's promotion and mismatch rules, source precedence, multi-day grouping, name canonicalization, Khmer numeral conversion, the store's confidence and atomic-replacement guarantees, upstream response and snapshot validation, public error/cache behavior, proxy handling, and the rate limiter. They do not hit the network.


Attribution

This project aggregates publicly available information. Credit where it is due:

  • Nager.Date — the open holiday API that supplies the bulk of the dates. If you find this project useful, consider supporting theirs.
  • Tallyfy — the public future-year Cambodia calendar used as a low-confidence cross-check.
  • Wikipedia — contributors to Public holidays in Cambodia, text under CC BY-SA 4.0.
  • National Bank of Cambodia (NBC), Agence Kampuchea Presse (AKP), Ministry of Labour and Vocational Training (MLVT), and the Royal Government of Cambodia — the official calendars, announcements, and legal instruments.

Holiday dates are facts and are not themselves copyrightable, but this project is not affiliated with or endorsed by the Royal Government of Cambodia or any ministry. The authoritative source is always the signed sub-decree.

Accuracy disclaimer

Provided "as is" under the MIT License, with no warranty. Dates marked computed or reported are not confirmed against the sub-decree. If you are using this for payroll, banking, contracts, or anything else where a wrong date has real consequences, filter with ?official=true and verify against the published Prakas.


Contributing

Issues and pull requests are welcome — particularly:

  • A working mef.gov.kh adapter. It is Cloudflare-blocked today; the hook (MEF_FETCH_CMD) is already there.
  • OCR for the MLVT Prakas PDF, which would let official confidence be reached without a human in the loop.
  • Corrections to holiday dates, ideally with a link to the sub-decree.
  • Additional sources — implement the sources.Source interface, register it in NewRegistry, and give it an honest Authority().

Please run make check (fmt + vet + test) before opening a PR.

If you spot a wrong date, open an issue with the year and a link to the official document — that is the most valuable contribution this project can receive.

About

Open-source API for Cambodian public holidays, filterable by day/month/year. Multi-source scraper with confidence levels so you can tell a confirmed date from a projection.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages