hebcal-api
A small Go microservice implementing a subset of the Hebcal.com REST APIs,
ported from the Node.js implementation in
hebcal-web. It reimplements the
JSON/XML APIs in Go for higher throughput and lower latency.
Currently implemented:
- Hebrew Date Converter (JSON and XML) — ported from
src/converter.js
- Zmanim (halachic times, JSON) — ported from
src/zmanim.js
- Assur Melacha ("is work prohibited right now", JSON) — the
im=1 mode
of the zmanim API
- Shabbat (candle-lighting / Torah portion, JSON) — ported from
src/shabbat.js; Torah readings come from the
readings-svc sidecar (see
Torah readings)
- Geolocation (
/geo) — resolve query parameters to a location, JSON
- Geo autocomplete (
/complete) — city/ZIP typeahead, JSON
- PDF calendars — the
download.hebcal.com/v4/…pdf downloads (ported
from src/pdf.js) and www.hebcal.com/holidays/hebcal-<year>.pdf
(ported from src/holidayPdf.js). These came from the separate
hebcal-pdf-go service, which this repository replaces.
- MCP server (
/mcp) — a Model Context Protocol server with seven
Hebrew-calendar tools, ported from the separate
hebcal-mcp service.
Date conversions use hebcal/hdate
(FromProlepticGregorian, matching JavaScript Date behavior); holidays,
parshiyot and zmanim come from
hebcal/hebcal-go (v0.16.2+), whose
solar calculations are backed by hebcal/noaa-go.
Endpoints
Hebrew Date Converter
GET|POST|HEAD /converter?cfg=json|xml&… — Gregorian ⇄ Hebrew date
conversion. The cfg parameter is required and must be json or xml
(400 otherwise). POST requests are accepted, but any request body is
ignored; conversion parameters always come from the URL query string.
g2h=1 with date=YYYY-MM-DD or gy/gm/gd (+ optional gs=on
for after sunset)
h2g=1 with hy/hm/hd (+ optional ndays=2..399 for a batch)
start=YYYY-MM-DD&end=YYYY-MM-DD for a batch of Gregorian dates
(cfg=json only, truncated to 399 days)
strict=1, i=on, lg=<lang>, callback=<fn> as documented
- If no date is given, the current date in
America/New_York is used
(and the response is marked non-cacheable).
GET|HEAD /converter/csv?… — CSV download listing the Gregorian dates
of the given Hebrew calendar date from 5 years before to 75 years after.
Zmanim
GET|HEAD /zmanim?cfg=json&… — halachic times for a location and date.
cfg=json is required. Requires the geonames/zips databases (see
Location databases); without them this route
returns 503 while the other APIs keep working.
- Date:
date=YYYY-MM-DD for a single day, or
start=YYYY-MM-DD&end=YYYY-MM-DD for a range (capped at 399 days). If
omitted, "today" in the location's timezone is used.
- Location — one of (see Location resolution):
geonameid, zip, city, decimal latitude+longitude+tzid, or
the legacy ladeg/lamin/ladir + lodeg/lomin/lodir form.
ue=1 includes the location's elevation in sunrise/sunset (and the
times derived from them); the seaLevelSunrise/seaLevelSunset times
are only present when elevation is enabled.
sec=1 returns seconds instead of rounding each time to the minute.
- Times that do not occur on a given day (e.g. no astronomical dawn in
the polar summer) are returned as
null.
GET|HEAD /zmanim?cfg=json&im=1&… — Assur Melacha check: whether
melacha (work) is prohibited at a given instant (Shabbat or Yom Tov).
Same location parameters as above.
dt=<ISO 8601> selects the instant (a bare YYYY-MM-DD is UTC
midnight; a datetime without a zone is interpreted in the location's
timezone; a trailing Z or ±HH:MM offset is honored). If dt is
omitted the current time is used and the response is cached for 60s.
Shabbat
GET|HEAD /shabbat?cfg=json&… — candle-lighting, the weekly Torah
portion, havdalah and the other events of one Shabbat week for a
location, ported from src/shabbat.js. cfg=json is required (501
otherwise). OPTIONS returns a CORS preflight; other methods return
405. Requires the geonames/zips databases (503 otherwise).
- Date — the week containing the given day, in this order of
precedence:
dt=YYYY-MM-DD, date=YYYY-MM-DD, start=YYYY-MM-DD
(end is accepted but ignored), or gy/gm/gd. With none of
them, "now" in the location's timezone is used and the response
expires at the end of Saturday rather than being cached for 7 days.
The window runs from that day (backing up to Friday when it is a
Saturday, so last night's candle-lighting is included) through the
later of the upcoming Saturday or five days ahead.
- Location — the same parameters as
/zmanim (see
Location resolution). Defaults to New York
when none is given.
b=<min> sets candle-lighting minutes before sunset; b=0 lights at
sunset itself. The default is 18, or the local custom in Israel (40 in
Jerusalem, 30 in Haifa and Zikhron Ya'akov, 20 elsewhere) — which also
replaces the b=18 the web form submits when the reader expressed no
preference.
M=on or td=<deg> ends Shabbat at a solar depression angle
(default 8.5°) and m=<min> at a fixed number of minutes after
sunset; m=0 suppresses havdalah entirely. When more than one is
given, td wins over m, M=on wins over both, and M=off picks
m over td.
ue=on folds the location's elevation into sunrise and sunset.
i=on puts a Diaspora location on the Israel schedule (the
candle-lighting custom still follows the location itself).
molad=on adds the molad announcement on Shabbat Mevarchim, with the
exact moment of the conjunction as a UTC instant.
yto=on keeps only the Yom Tov days; a week without one returns an
empty items array.
h12=0 forces a 24-hour clock and h12=1 a 12-hour one, overriding
the location's country.
lg=<lang> translates the event titles (a=on is the much older
spelling of lg=a); an unsupported locale returns
400 {"error":"Locale 'xx' not found"}, as hebcal-web does here.
hdp=1 adds heDateParts.
leyning=off (or leyning=0) omits the Torah readings; see below.
callback=<fn> wraps the response in a JSONP call. A callback longer
than 128 characters or that is not a plain dotted identifier is
ignored, and ordinary JSON is returned.
Torah readings
Each non-timed item carries a leyning object — the aliyot, torah
summary, haftarah (plus the haftarah_sephardic and haftarah_chabad
variants where they differ), maftir, and, for a parsha, the triennial
cycle:
{"1":"Deuteronomy 11:26-12:10","…":"…","torah":"Deuteronomy 11:26-16:17",
"haftarah":"Isaiah 54:11-55:5","maftir":"Deuteronomy 16:13-16:17",
"triennial":{"1":"Deuteronomy 11:26-11:31","…":"…"}}
hebcal-go has no leyning data, so the readings come from the
readings-svc sidecar's /leyning
endpoint, over a unix domain socket set with -readings-socket or
READINGS_SOCKET (default /run/hebcal/readings-svc.sock). That service
answers in @hebcal/rest-api's classic-API shape, so each reading is passed
through into the response verbatim, key order and all, rather than being
reformatted here.
Readings depend only on the date and on Israel vs. Diaspora — every city
in Israel reads the same portion on a given day, as does every city in the
Diaspora — so they are cached in a 400-entry LRU keyed by (date, il),
which a week's worth of requests for any location shares. /leyning takes no
locale at all: the readings are locale-invariant, and items are matched to
hebcal-go's events by the untranslated event description, so they stay English
however lg was set.
When readings-svc cannot be reached, /shabbat returns 503 rather than
a response that silently omits the readings; leyning=off skips the call
altogether.
Geolocation
-
GET|HEAD /geo?… — resolve a location from query parameters and
return the location as JSON (ported from the /geo route in
hebcal-web's src/router.js). OPTIONS returns a CORS preflight; other
methods return 405. Accepts the same location parameters as
/zmanim (see Location resolution), and returns
the raw @hebcal/core Location shape:
{"latitude":31.76904,"longitude":35.21633,"locationName":"Jerusalem, Israel","timeZoneId":"Asia/Jerusalem","elevation":786,"il":true,"cc":"IL","geoid":281184,"admin1":"Jerusalem District","geo":"geoname","population":801000,"asciiname":"Jerusalem","geonameid":281184}
This differs from the trimmed location object embedded in the /zmanim
and /shabbat responses (different key names, and it always includes
elevation, il, geoid and population). A request with no location
parameters returns 204 No Content; an unknown geonameid/zip/city
returns 404, and malformed input returns 400. Requires the
geonames/zips databases (503 otherwise).
Geo autocomplete
-
GET /complete?q=<prefix> (also /complete.php) — city and US-ZIP
typeahead, ported from hebcal-web's src/complete.js. Returns a JSON
array of up to 12 matches, each with a country-flag emoji. A leading
digit is treated as a ZIP code (exact 5-digit or numeric prefix);
otherwise both the geonames and US-ZIP full-text indexes are searched,
merged (GeoNames winning ties), and sorted by population.
g=on (or g=1) additionally returns
latitude/longitude/timezone/population, plus elevation when it
is positive (both ZIP and geoname results; this is not something
@hebcal/geo-sqlite ever returned).
- An empty
q or no matches returns 404 {"error":"Not Found"}.
- Responses are cached for 3 days with a weak
ETag.
[{"id":281184,"value":"Jerusalem, Israel","admin1":"Jerusalem District","country":"Israel","cc":"IL","geo":"geoname","asciiname":"Jerusalem","flag":"🇮🇱"}]
The full-text queries use SQLite FTS5, so the mattn/go-sqlite3 driver
must be built with the sqlite_fts5 tag (the Makefile and CI already
pass -tags sqlite_fts5).
PDF calendars
Each calendar is US Letter, landscape, one Gregorian or Hebrew month per
page, with holidays, candle-lighting and Havdalah times, Torah readings,
daily-learning schedules, the Omer, alternate (Hebrew or Gregorian) dates,
and tracked links over every event. Hebrew and other right-to-left text is
laid out with real Unicode bidi and OpenType shaping, and event titles are
available in 13 locales. Rendering needs the fonts (see
Fonts); without them these two routes answer 503 and the JSON
APIs are unaffected.
GET|HEAD /v4/<base64>/<filename>.pdf — render a calendar. The
<base64> segment is a Download protobuf message (defined in
pkg/downloadpb/download.proto, shared with hebcal-web) carrying the
calendar options: year or month range, location, event categories,
locale, candle-lighting and Havdalah preferences, daily-learning series,
and so on. These URLs are produced by the hebcal.com download form. A
request whose options select no events returns 400; an unknown location
returns 404; a year outside the supported range returns 410. A
rendered PDF is served with a 14-day Cache-Control and a weak ETag; a
conditional request whose If-None-Match still matches gets 304 Not Modified.
GET|HEAD /v2/h/<base64>/<filename>.pdf — the same calendar from the
older URL form, still linked from a decade of pages and still crawled.
Here the <base64> segment is a query string rather than a protobuf
(v=1&geonameid=5128581&year=2026&c=on&…). hebcal-web answers these with
a 301 to the /v4/ form; this service converts the query string to the
same Download message that redirect would have carried and renders it,
so the response is a 200 with the identical calendar and headers. Two
location forms downloadHref2() has no branch for — a legacy city=
identifier and the ladeg/lamin/ladir degrees-and-minutes form — are
resolved rather than dropped, which is what these URLs rendered before
hebcal-web's redirect was added. Only
.pdf under /v2/h/ is served: the .ics feeds and the yahrzeit
calendars sharing that prefix get 404, as does a URL with no v=.
GET|HEAD /holidays/hebcal-<year>.pdf — render a year of Jewish
holidays, the calendar www.hebcal.com links from its holiday pages. A
year of 3761 or more is a Hebrew year, as is the Gregorian-span form the
year-index pages use, hebcal-2026-2027.pdf; anything smaller is a
Gregorian year. ?i=on selects the Israel schedule, and that is the only
parameter: these calendars are always English, as every link to them on
the website is. Every day carries its Hebrew date, and there are no
candle-lighting times, since they have no location. A URL that is not a
holiday calendar returns 404, a year outside 1..32000 returns 400,
and a year with no calendar returns 410. A rendered PDF is served with
a 60-day Cache-Control — four times the download path's, since these
depend on nothing but the year — and the same weak ETag and 304
handling. Nothing else under /holidays/ belongs to this service: the
HTML pages there are still hebcal-web's, and this route answers them
404.
Daily learning
hebcal-web offers 20 daily-learning series. Thirteen of them — Daf Yomi,
Mishna Yomi, Nach Yomi, the two Yerushalmi editions, Perek Yomi,
Daf-a-Week, 929, Psalms, the two Rambam schedules, Tanakh Yomi and Pirkei
Avot — are generated in-process by
hebcal/learning. The remaining six —
Sefer HaMitzvot, Kitzur Shulchan Arukh, Arukh HaShulchan, Amud HaYomi
(Dirshu), Chofetz Chaim and Shemirat HaLashon — are fetched from the
readings-svc sidecar's
/learning endpoint and merged into the calendar, so every series still
renders. Each learning row links to its source on Sefaria (or
dafyomi.org).
-readings-socket (or READINGS_SOCKET) is the same socket /shabbat
uses for Torah readings; the two endpoints are served by one process.
Neither failure mode serves a calendar quietly missing rows the reader
asked for; both name the missing series in an X-Unsupported-Series
header (which the access log records as unsupported):
- 501 when
-readings-socket is empty — this build cannot render
those series and retrying will not help (unreachable in the default
configuration).
- 503, with
Retry-After, when a configured readings-svc does not
answer — transient, and worth retrying or falling back to the Node.js
service.
Fonts
The calendars are drawn with the Source Sans Pro and Adobe Hebrew families,
checked into this repository under fonts/. -fonts (or FONT_DIR,
default fonts) names a directory holding Source_Sans_Pro/ and
Adobe_Hebrew/. The fonts are parsed once at startup and shared by every
request; only the per-document embedded instances are rebuilt. A failure to
load them is logged and disables the two PDF routes rather than stopping
the server.
MCP server
POST /mcp — a stateless Model Context Protocol
server (streamable-HTTP transport), served at www.hebcal.com/mcp and ported
from hebcal-mcp using the official
modelcontextprotocol/go-sdk.
It exposes seven tools: convert-gregorian-to-hebrew,
convert-hebrew-to-gregorian, yahrzeit, torah-portion,
jewish-holidays-year, daf-yomi, and shabbat-times. All compute
in-process; torah-portion's reading summary comes from the readings-svc
sidecar's /shabbatTorahReading route and is omitted if the sidecar is
unreachable. GET and DELETE return 405.
Operational
GET /ping — health check. Serves the contents of /var/www/html/ping
(override with -pingfile) as text/plain, the same file hebcal-web
serves; returns 404 when the file is absent, so removing it takes the
host out of load-balancer rotation.
GET /metrics — Prometheus metrics, including http_requests_total.
Location resolution
The /zmanim API accepts the same location parameters as hebcal-web, in
this order of precedence:
geonameid=<id> — a GeoNames numeric id.
zip=<5-digit> — a US ZIP code.
city=<id> — a legacy Hebcal city identifier (e.g. GB-London).
latitude=<deg>&longitude=<deg>&tzid=<IANA tz> — decimal degrees, with
south/west expressed as negative numbers. elev=<meters> is optional
(used only with ue=1), and i=on selects the Israel schedule.
ladeg/lamin/ladir + lodeg/lomin/lodir — the legacy
degree/minute/direction form, where south/west are positive magnitudes
with a direction letter (s/w). A legacy tz/dst pair is mapped to
an IANA timezone when tzid is absent.
Unlike hebcal-web, this service does not guess a timezone from
latitude/longitude shape data, so tzid (or a resolvable tz/dst) is
required for the positional forms. GeoIP-based location is also out of
scope.
Location databases
Location resolution reads two prebuilt SQLite databases,
geonames.sqlite3 and zips.sqlite3, produced by
@hebcal/geo-sqlite. Their paths
default to the working directory and can be set with the -zips-db /
-geonames-db flags or the ZIPS_DB / GEONAMES_DB environment
variables. Small sample databases used by the tests live in testdata/.
Caching and compression
Responses include weak ETag validators (FNV-1a; the Node.js service uses
murmurhash3 — weak ETags do not need to match across implementations),
appropriate Cache-Control or Expires headers, CORS headers, and
dynamic brotli or gzip compression (brotli preferred) for bodies larger
than 512 bytes — a threshold chosen empirically: multi-day batches and
event-heavy XML just above it shrink 40–60%, while typical single-date
JSON below it saves almost nothing (see TestThresholdExperiment).
Zmanim caching mirrors hebcal-web: a single live date expires at the next
local midnight; an explicit date or range is cached for 30 days with an
ETag; the live Assur Melacha check is cached for 60 seconds.
Known differences from the Node.js implementation
- Same-day events may appear in a slightly different order within the
events array.
- Molad announcements are rendered in hebcal-go's format rather than
@hebcal/core's.
strict=1 validation errors return a clean {"error": "..."} object
without the stack trace that koa-error appends in development mode.
- Zmanim times agree with @hebcal/core to within ~2 seconds (the inherent
difference between the noaa-go and @hebcal/core NOAA implementations);
minute-rounded output matches except where a value falls within 2s of a
rounding boundary.
/shabbat honors date= and start= as ways of pinning the week;
hebcal-web reads only dt= and gy/gm/gd there and quietly falls
back to today for the others.
/shabbat with yto=on and no Yom Tov in the week returns 200 and an
empty items array; hebcal-web applies the filter before its own
"no events" check and answers 400.
/shabbat with b=0 recomputes the candle-lighting times after the
calendar is built: hebcal-go's CheckCandleOptions rewrites a zero
CandleLightingMins to the 18/20-minute default, so there is no way to
ask it for sunset itself. Drop the workaround if hebcal-go grows one.
For the PDF calendars, where the bar is that a rendered calendar is
indistinguishable from the one production serves for the same URL:
- Chanukah candle-lighting order on Saturday night. On a motzei-Shabbat
Chanukah day the same rows and times appear, but the "Chanukah: N
Candles" candle-lighting row is grouped with the day's other timed rows
at the foot of the cell here, where production places it at the top. Same
content, an accepted ordering difference — not a missing, extra, or
merged row.
- Zmanim differ by up to about a minute, for the same reason as the
JSON routes above.
- Day numbers sit ~3.3pt lower than www.hebcal.com (and the month title
~6pt), a small, uniform vertical offset in the Latin faces; horizontal
alignment is unaffected. The currently deployed download.hebcal.com
places them exactly where this service does, so the offset shows up only
against the newer of the two production builds.
- A localized
/v4/ alternate date reads 12 Tewet, not 12. Tewet.
The day line comes from hebcal-go's hebrewDateEvent.Render(), which is
behind @hebcal/hdate on the ordinal it writes for locales other than
English and Spanish. The fix belongs in hebcal-go.
- Two holidays on one day can be ordered differently. hebcal-go sorts a
date's holidays alphabetically; @hebcal/core keeps the order it created
them in. So a cell holding both Rosh Chodesh Elul and Rosh Hashana
LaBehemot, or both Erev Purim and Shabbat Zachor, can list them the other
way round — one or two cells a year, with no row missing, added or
retimed.
- An unknown-location
404 is not cached. hebcal-web lets its 14-day
Cache-Control survive onto the 404; this service omits it there, since
a location missing today may be added later. The out-of-range 410 and
the rendered PDF are cached as in production.
Package layout
The service follows the standard Go microservice layout: cmd wires
dependencies and starts the listener, internal holds the application, and
pkg holds the two pieces that are useful on their own.
cmd/hebcal-api/ main: config, logger, data sources, HTTP listener
internal/
config/ flag/env configuration and build metadata
handler/ transport layer: one file per route, plus the mux
service/ business logic, one package per route
converter/ /converter parsing and JSON/XML/CSV rendering
zmanim/ /zmanim halachic times and date resolution
shabbat/ /shabbat calendar, candle options, item rendering
complete/ /complete result serialization
location/ query -> location, and the two location JSON shapes
pdf/ /v4/ and /v2/ PDF calendars: protobuf -> options,
event generation, page layout, shaping, fonts, links
holidaypdf/ /holidays/hebcal-<year>.pdf URL parsing
repository/
readings/ client for the readings-svc sidecar (/leyning
for /shabbat, /learning for the PDF calendars)
over its unix domain socket
model/ domain layer: dates, locales, calendar events, errors
httpx/ shared transport utilities: ETag, CORS, content types,
error rendering, compression/metrics/logging middleware
jsutil/ JavaScript-compatibility helpers (parseInt,
JSON.stringify, ordered objects, query conventions)
logger/ pino-compatible JSON access log
pkg/
geodb/ SQLite geonames/zips reader and geographic typeahead;
a Go port of @hebcal/geo-sqlite
geoip/ client for the geoip2 unix-socket lookup service
downloadpb/ the Download protobuf a /v4/ URL carries, shared
with hebcal-web
tools/ porting tools: PDF comparison, width measurement,
locale and URL dumps (not part of the build)
pkg/geodb, pkg/geoip and pkg/downloadpb depend only on their own
third-party libraries,
never on internal, so either can be reused by another program or split out
into its own module without untangling anything first. geodb embeds
city2geonameid.json and carries the US state-name table, so it needs no
data files beyond the two SQLite databases.
The dependency direction is one-way: handler → service → repository,
model, pkg. Nothing in service writes to an http.ResponseWriter —
the handlers own status codes, headers, and cache validators, and the
services return values.
Build and test
Requires Go >= 1.24 and cgo (a C compiler), because the location
lookups use the cgo-based github.com/mattn/go-sqlite3 driver. The driver
must be built with the sqlite_fts5 tag so the /complete full-text
queries work; the Makefile targets pass it for you.
make build # builds ./hebcal-api from ./cmd/hebcal-api (CGO_ENABLED=1)
make test # runs the unit tests
make vet # go vet
make fmt # gofmt -w cmd internal pkg
If you invoke go directly rather than through the Makefile, add the
tag yourself, e.g. go test -tags sqlite_fts5 ./....
The tests that render a calendar need the fonts: they look for $FONT_DIR,
then a fonts directory at the repository root, and skip rather than fail
when neither is present.
Run
./hebcal-api # listens on :8082, logs to stdout
./hebcal-api -port 8082 -logfile /var/log/hebcal/api.log \
-zips-db /var/lib/hebcal/zips.sqlite3 \
-geonames-db /var/lib/hebcal/geonames.sqlite3 \
-readings-socket /run/hebcal/readings-svc.sock \
-fonts /var/www/fonts
The port defaults to 8082 (or the PORT environment variable); the
access log defaults to stdout (pass -logfile <path>). The geonames/zips
database paths default to the working directory (see
Location databases). /shabbat needs readings-svc
listening on -readings-socket (or READINGS_SOCKET) for Torah readings,
and the PDF calendars need -fonts (see Fonts).
Access logs are pino-compatible JSON lines, e.g.:
{"level":30,"time":1783224620662,"pid":46493,"hostname":"w44","status":200,"length":217,"duration":1,"ip":"1.2.3.4","method":"GET","url":"/converter?cfg=json&gy=2026&gm=7&gd=4&g2h=1","ua":"curl/8.5.0"}
Sending SIGUSR1 (or SIGHUP) makes the server close and reopen the
access log file, for use with logrotate.
Deploy (Debian 13)
sudo make install # installs binary, systemd unit, logrotate config
sudo systemctl start hebcal-api
make install installs the binary to /usr/local/bin, the systemd unit
to /etc/systemd/system/hebcal-api.service, and the logrotate drop-in to
/etc/logrotate.d/hebcal-api. The service runs as www-data and writes
its access log to /var/log/hebcal/api.log (same directory hebcal-web
uses), rotated daily; logrotate signals the service with SIGUSR1 to
reopen the file after rotation.
Varnish decides which URLs reach this service. Alongside the JSON APIs, two
PDF families now belong here rather than to the Node.js service:
download.hebcal.com/v4/**.pdf (with its legacy /v2/h/**.pdf spelling)
and www.hebcal.com/holidays/hebcal-*.pdf (and nothing else under
/holidays/). Both used to be served by the
separate hebcal-pdf-go process on port 8083, which this service replaces —
route them to 8082 and retire that backend.