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=1mode 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/…pdfdownloads (ported fromsrc/pdf.js) andwww.hebcal.com/holidays/hebcal-<year>.pdf(ported fromsrc/holidayPdf.js). These came from the separate hebcal-pdf-go service, which this repository replaces.
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.
GET|POST|HEAD /converter?cfg=json|xml&…— Gregorian ⇄ Hebrew date conversion. Thecfgparameter is required and must bejsonorxml(400 otherwise). POST requests are accepted, but any request body is ignored; conversion parameters always come from the URL query string.g2h=1withdate=YYYY-MM-DDorgy/gm/gd(+ optionalgs=onfor after sunset)h2g=1withhy/hm/hd(+ optionalndays=2..399for a batch)start=YYYY-MM-DD&end=YYYY-MM-DDfor 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_Yorkis 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.
GET|HEAD /zmanim?cfg=json&…— halachic times for a location and date.cfg=jsonis 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-DDfor a single day, orstart=YYYY-MM-DD&end=YYYY-MM-DDfor 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, decimallatitude+longitude+tzid, or the legacyladeg/lamin/ladir+lodeg/lomin/lodirform. ue=1includes the location's elevation in sunrise/sunset (and the times derived from them); theseaLevelSunrise/seaLevelSunsettimes are only present when elevation is enabled.sec=1returns 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.
- Date:
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 bareYYYY-MM-DDis UTC midnight; a datetime without a zone is interpreted in the location's timezone; a trailingZor±HH:MMoffset is honored). Ifdtis omitted the current time is used and the response is cached for 60s.
GET|HEAD /shabbat?cfg=json&…— candle-lighting, the weekly Torah portion, havdalah and the other events of one Shabbat week for a location, ported fromsrc/shabbat.js.cfg=jsonis required (501 otherwise).OPTIONSreturns a CORS preflight; other methods return405. 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(endis accepted but ignored), orgy/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=0lights 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 theb=18the web form submits when the reader expressed no preference.M=onortd=<deg>ends Shabbat at a solar depression angle (default 8.5°) andm=<min>at a fixed number of minutes after sunset;m=0suppresses havdalah entirely. When more than one is given,tdwins overm,M=onwins over both, andM=offpicksmovertd.ue=onfolds the location's elevation into sunrise and sunset.i=onputs a Diaspora location on the Israel schedule (the candle-lighting custom still follows the location itself).molad=onadds the molad announcement on Shabbat Mevarchim, with the exact moment of the conjunction as a UTCinstant.yto=onkeeps only the Yom Tov days; a week without one returns an emptyitemsarray.h12=0forces a 24-hour clock andh12=1a 12-hour one, overriding the location's country.lg=<lang>translates the event titles (a=onis the much older spelling oflg=a); an unsupported locale returns400 {"error":"Locale 'xx' not found"}, as hebcal-web does here.hdp=1addsheDateParts.leyning=off(orleyning=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.
- Date — the week containing the given day, in this order of
precedence:
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.
-
GET|HEAD /geo?…— resolve a location from query parameters and return the location as JSON (ported from the/georoute in hebcal-web'ssrc/router.js).OPTIONSreturns a CORS preflight; other methods return405. Accepts the same location parameters as/zmanim(see Location resolution), and returns the raw@hebcal/coreLocation 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
locationobject embedded in the/zmanimand/shabbatresponses (different key names, and it always includeselevation,il,geoidandpopulation). A request with no location parameters returns204 No Content; an unknowngeonameid/zip/cityreturns404, and malformed input returns400. Requires the geonames/zips databases (503 otherwise).
-
GET /complete?q=<prefix>(also/complete.php) — city and US-ZIP typeahead, ported from hebcal-web'ssrc/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(org=1) additionally returnslatitude/longitude/timezone/population.- An empty
qor no matches returns404 {"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-sqlite3driver must be built with thesqlite_fts5tag (theMakefileand CI already pass-tags sqlite_fts5).
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 aDownloadprotobuf message (defined inpkg/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 returns400; an unknown location returns404; a year outside the supported range returns410. A rendered PDF is served with a 14-dayCache-Controland a weakETag; a conditional request whoseIf-None-Matchstill matches gets304 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 a301to the/v4/form; this service converts the query string to the sameDownloadmessage that redirect would have carried and renders it, so the response is a200with the identical calendar and headers. Two location formsdownloadHref2()has no branch for — a legacycity=identifier and theladeg/lamin/ladirdegrees-and-minutes form — are resolved rather than dropped, which is what these URLs rendered before hebcal-web's redirect was added. Only.pdfunder/v2/h/is served: the.icsfeeds and the yahrzeit calendars sharing that prefix get404, as does a URL with nov=.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=onselects 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 returns404, a year outside 1..32000 returns400, and a year with no calendar returns410. A rendered PDF is served with a 60-dayCache-Control— four times the download path's, since these depend on nothing but the year — and the same weakETagand304handling. Nothing else under/holidays/belongs to this service: the HTML pages there are still hebcal-web's, and this route answers them404.
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-socketis 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.
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.
GET /ping— health check. Serves the contents of/var/www/html/ping(override with-pingfile) astext/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, includinghttp_requests_total.
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 withue=1), andi=onselects 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 legacytz/dstpair is mapped to an IANA timezone whentzidis 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 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/.
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.
- Same-day events may appear in a slightly different order within the
eventsarray. - Molad announcements are rendered in hebcal-go's format rather than @hebcal/core's.
strict=1validation 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.
/shabbathonorsdate=andstart=as ways of pinning the week; hebcal-web reads onlydt=andgy/gm/gdthere and quietly falls back to today for the others./shabbatwithyto=onand no Yom Tov in the week returns200and an emptyitemsarray; hebcal-web applies the filter before its own "no events" check and answers400./shabbatwithb=0recomputes the candle-lighting times after the calendar is built: hebcal-go'sCheckCandleOptionsrewrites a zeroCandleLightingMinsto 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 reads12 Tewet, not12. Tewet. The day line comes from hebcal-go'shebrewDateEvent.Render(), which is behind@hebcal/hdateon 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
404is not cached. hebcal-web lets its 14-dayCache-Controlsurvive onto the 404; this service omits it there, since a location missing today may be added later. The out-of-range410and the rendered PDF are cached as in production.
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.
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 pkgIf 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.
./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/fontsThe 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.
sudo make install # installs binary, systemd unit, logrotate config
sudo systemctl start hebcal-apimake 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.