A Go/Gofiber prayer time API server that scrapes prayer times from JAKIM's e-Solat and serves them in a v1-compatible REST API with GPS zone auto-detection. This API is live at api.waktusolat.online. Its structure and availability are provided on a best-effort basis and may change without notice.
# Clone and build
git clone https://github.com/shabilullah/gowaktusolat.git
cd gowaktusolat
go build ./cmd/scraper && go build ./cmd/server
# Seed zones and scrape current year prayer times (~80s for all 53 zones).
# On first startup, the server auto-seeds zones + current year data.
# These CLI commands are for manual runs or custom DB paths:
./scraper seed-zones
./scraper scrape --year=$(date +%Y)
# Start the API server (auto-seeds on first run, scraper runs in background)
./server
# Server listening on http://localhost:8080
## Development
```bash
# Install air (one-time)
go install github.com/air-verse/air@latest
# Start with hot-reload — rebuilds on file changes
# (uses .air.toml pre-configured to build cmd/server)
air
# Or run directly (no hot-reload)
go run ./cmd/serverAll settings are read from a .env file at the project root (optional). Copy .env.example to .env and edit:
cp .env.example .env| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Server listen port |
DB_PATH |
data/waktusolat.db |
SQLite database file path |
BASE_PATH |
"" |
URL prefix (reserved) |
CORS_ORIGINS |
* |
* for all, or comma-separated domains |
PREFORK |
false |
Spawn one process per CPU core (true/1) |
API_KEY |
"" |
Protects POST /api/cache/reset when set; send as X-API-Key header |
SEEDER_SCHED |
"" |
Cron schedule for auto-scheduler; omit to disable |
YEAR |
"" |
Comma-separated years to scrape on startup (e.g. 2024,2025); skips already-scraped years |
CORS examples:
# Allow all origins (default)
CORS_ORIGINS=*
# Single domain
CORS_ORIGINS=https://myapp.com
# Multiple domains
CORS_ORIGINS=https://myapp.com,https://admin.myapp.com,https://api.myapp.com| Driver | Package | CGO | Notes |
|---|---|---|---|
| SQLite | zombiezen.com/go/sqlite |
Yes | Native API, connection pooling via sqlitex.Pool, WAL mode |
All code uses the zombiezen.com/go/sqlite native API — there is no database/sql abstraction. A sqlitex.Pool (4 connections for the server, 1 for the scraper) is shared across all handlers via pool.Take(ctx) / pool.Put(conn). In-memory databases with ?cache=shared are used in tests.
JSON encoding uses goccy/go-json across the board — both via fiber.Config{JSONEncoder, JSONDecoder} for API responses, and client.SetJSONUnmarshal in the scraper.
Concurrency: The pool serializes writes across connections while allowing concurrent reads. Writes are rare (settings, scheduled scraper) so contention is negligible. Prefork is safe — each worker process gets its own pool.
go test ./internal/...# Pull and run
docker run -d \
--name gowaktusolat \
-p 8080:8080 \
-v ./data:/data \
-e CORS_ORIGINS=* \
-e PREFORK=false \
-e API_KEY= \
-e SEEDER_SCHED= \
-e YEAR= \
ghcr.io/shabilullah/gowaktusolat:master
# Or use docker compose
docker compose up -dThe server auto-seeds zones and current-year prayer times on first run (~80s in background).
Set SEEDER_SCHED to a cron expression (e.g. 0 2 1 1 * for yearly on Jan 1 at 2am) to enable auto-scraping on schedule.
Omit it or leave empty to disable recurring scrapes.
Set YEAR to a comma-separated list of years (e.g. 2024,2025) to scrape historical data on startup.
Years already present in the database are skipped — re-scraping is idempotent but avoided.
Edit docker-compose.yml to change settings — all environment variables are listed inline with comments.
The .env file is also mounted (optional) for the Go app's built-in .env loader.
Manual seed/scrape from the host (against the volume-mounted DB):
go run ./cmd/scraper seed-zones
go run ./cmd/scraper scrape --year=$(date +%Y)Images are published automatically to ghcr.io/shabilullah/gowaktusolat on every push to master and on version tags (v1.0.0 → :1.0, :1, :latest).
# Requires CGO (zombiezen.com/go/sqlite wraps native SQLite).
# Cross-compile for Linux AMD64:
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o server ./cmd/server
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o scraper ./cmd/scraper- Copy
server,scraperbinaries anddata/directory to the target host. - Seed zones and scrape initial data (or skip — the server auto-seeds on first start):
./scraper seed-zones ./scraper scrape --year=$(date +%Y) - Create the systemd unit:
# /etc/systemd/system/gowaktusolat.service [Unit] Description=Go Waktu Solat API After=network.target [Service] Type=simple User=nobody WorkingDirectory=/opt/gowaktusolat Environment=PORT=8080 Environment=DB_PATH=/opt/gowaktusolat/data/waktusolat.db Environment=SEEDER_SCHED=0 2 1 1 * Environment=YEAR=2024,2025 ExecStart=/opt/gowaktusolat/server Restart=always RestartSec=5 [Install] WantedBy=multi-user.target
- Enable and start:
sudo systemctl enable --now gowaktusolat Set `SEEDER_SCHED` in the unit file to enable recurring scrapes, and `YEAR` to scrape historical years on startup. The server checks which years are already in the database and only scrapes missing ones.
server {
listen 80;
server_name solat.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}All endpoints return Content-Type: application/json unless noted. Cached routes include Cache-Control: public, max-age=3600.
GET /api/zones — List all zones
GET /api/zones
Response 200 — array of zone objects
[
{"jakimCode": "JHR01", "negeri": "Johor", "daerah": "Pulau Aur dan Pulau Pemanggil"},
{"jakimCode": "SGR01", "negeri": "Selangor", "daerah": "Gombak, Petaling, Sepang, Hulu Langat, Hulu Selangor, Shah Alam"}
]| Field | Type | Description | Example |
|---|---|---|---|
jakimCode |
string |
JAKIM zone code | "SGR01" |
negeri |
string |
State name (Malay) | "Selangor" |
daerah |
string |
District(s) covered | "Gombak, Petaling, Sepang, Hulu Langat, ..." |
GET /api/zones/:state — Filter zones by state prefix
GET /api/zones/SGR
Response 200 — filtered array
[
{"jakimCode": "SGR01", "negeri": "Selangor", "daerah": "Gombak, Petaling, Sepang, Hulu Langat, Hulu Selangor, Shah Alam"},
{"jakimCode": "SGR02", "negeri": "Selangor", "daerah": "Kuala Selangor, Sabak Bernam"},
{"jakimCode": "SGR03", "negeri": "Selangor", "daerah": "Klang, Kuala Langat"}Fields — same as GET /api/zones above (all three-letter codes match the :state prefix).
GET /api/zones/:lat/:long — Detect zone by GPS coordinate
GET /api/zones/3.068498/101.630263
Response 200 — detected zone
{"zone": "SGR01", "state": "SGR", "district": "Petaling"}Fields
| Field | Type | Description | Example |
|---|---|---|---|
zone |
string |
Detected JAKIM zone | "SGR01" |
state |
string |
Three-letter code | "SGR" |
district |
string |
Matched district name | "Petaling" |
Errors
| Status | Body | Cause |
|---|---|---|
422 |
{"message":"Invalid latitude"} |
Non-numeric lat/long |
404 |
{"message":"No zone found for the given coordinates"} |
Point outside Malaysia |
GET /api/solat/:zone — Month of prayer times
GET /api/solat/SGR01?month=6&year=2026
Query parameters
| Param | Type | Default | Constraints |
|---|---|---|---|
month |
int |
current month | 1–12 |
year |
int |
current year | >= 2020 |
Response 200
{
"prayerTime": [
{
"hijri": "1447-12-15",
"date": "01-Jun-2026",
"day": "Monday",
"imsak": "05:39:00",
"fajr": "05:49:00",
"syuruk": "07:01:00",
"dhuha": "07:26:00",
"dhuhr": "13:14:00",
"asr": "16:39:00",
"maghrib": "19:22:00",
"isha": "20:37:00"
}
],
"status": "OK!",
"serverTime": "2026-06-26 20:34:11",
"periodType": "month",
"lang": "",
"zone": "SGR01",
"bearing": ""
}
**Wrapper fields**
| Field | Type | Description | Example |
|--------------|----------|--------------------------------------------------|-----------------------------|
| `prayerTime` | `array` | List of daily prayer time objects (see below) | `[{"hijri":"…","date":"…"}]` |
| `status` | `string` | `"OK!"` on success | `"OK!"` |
| `serverTime` | `string` | Server timestamp in `YYYY-MM-DD HH:MM:SS` | `"2026-06-26 20:34:11"` |
| `periodType` | `string` | `"month"` for month queries, `"day"` for day | `"month"` |
| `lang` | `string` | Reserved, always `""` | `""` |
| `zone` | `string` | JAKIM zone code queried | `"SGR01"` |
| `bearing` | `string` | Reserved, always `""` | `""` |
**`prayerTime[]` fields**
| Field | Type | Description | Example |
|-----------|----------|-------------------------------------------------------|-------------------|
| `hijri` | `string` | Hijri date in `YYYY-MM-DD` format | `"1447-12-15"` |
| `date` | `string` | Gregorian date in `DD-Mon-YYYY` format | `"01-Jun-2026"` |
| `day` | `string` | Weekday name in English (`"Monday"`–`"Sunday"`) | `"Monday"` |
| `imsak` | `string` | Imsak time in `HH:MM:SS` (24h) | `"05:39:00"` |
| `fajr` | `string` | Fajr/Subuh time in `HH:MM:SS` (24h) | `"05:49:00"` |
| `syuruk` | `string` | Sunrise time in `HH:MM:SS` (24h) | `"07:01:00"` |
| `dhuha` | `string` | Dhuha time in `HH:MM:SS` (24h) | `"07:26:00"` |
| `dhuhr` | `string` | Dhuhr/Zohor time in `HH:MM:SS` (24h) | `"13:14:00"` |
| `asr` | `string` | Asr time in `HH:MM:SS` (24h) | `"16:39:00"` |
| `maghrib` | `string` | Maghrib time in `HH:MM:SS` (24h) | `"19:22:00"` |
| `isha` | `string` | Isha/Isyak time in `HH:MM:SS` (24h) | `"20:37:00"` |
**Errors**
| Status | Body | Cause |
|--------|------|-------|
| `404` | `{"message":"No data found for zone: XXXXX for JUNE/2026"}` | No data scraped |
</details>
<details>
<summary><code>GET /api/solat/:zone/:day</code> — Single day prayer time</summary>
GET /api/solat/SGR01/15?month=6&year=2026
**Query parameters** — same as month endpoint.
**Response** `200` — `prayerTime` is a single object, `periodType` is `"day"`
```json
{
"prayerTime": {
"hijri": "1447-12-29",
"date": "15-Jun-2026",
"day": "Monday",
"imsak": "05:30:00",
"fajr": "05:40:00",
"syuruk": "07:05:00",
"dhuha": "07:22:00",
"dhuhr": "13:15:00",
"asr": "16:30:00",
"maghrib": "19:12:00",
"isha": "20:27:00"
},
"status": "OK!",
"serverTime": "2026-06-26 20:34:11",
"periodType": "day",
"lang": "",
"zone": "SGR01",
"bearing": ""
}
Fields — same wrapper and prayerTime fields as the month endpoint. prayerTime is a single object (not an array) and periodType is "day".
Errors
| Status | Body | Cause |
|---|---|---|
400 |
{"message":"Invalid day parameter"} |
Non-numeric or out of range |
400 |
{"message":"Day 32 out of range for June/2026"} |
Day exceeds month length |
404 |
Same as month endpoint | No data |
GET /api/solat/gps/:lat/:long — Month by GPS (auto-detect zone)
GET /api/solat/gps/3.068498/101.630263?month=6&year=2026
Query parameters — same as month endpoint.
Response 200 — same wrapper and prayerTime fields as the month endpoint. zone reflects the auto-detected JAKIM code.
Path parameters
| Param | Type | Description | Example |
|---|---|---|---|
lat |
float |
Latitude | 3.068498 |
long |
float |
Longitude | 101.630263 |
Errors — same GPS errors as /api/zones/:lat/:long (422, 404), plus the standard zone-not-found.
GET /api/jadual_solat/:zone — Printable PDF prayer timetable
GET /api/jadual_solat/SGR01?month=6&year=2026 # single month
GET /api/jadual_solat/SGR01?year=2026 # full year (12 pages)
Query parameters
| Param | Type | Default | Description | Example |
|---|---|---|---|---|
month |
int |
(none) | 1–12; omit to generate all 12 months | 6 |
year |
int |
current | >= 2020 | 2026 |
Response 200 — Content-Type: application/pdf
Landscape A4 PDF. Single-month requests return one page; year-only requests return 12 pages (one per month). Columns: Tarikh, Subuh, Syuruk, Zohor, Asar, Maghrib, Isyak.
Errors
| Status | Body | Cause |
|---|---|---|
404 |
{"message":"No data found for zone: ..."} |
No data |
The scraper schedule is controlled exclusively via the SEEDER_SCHED environment variable — there is no runtime API to enable, disable, or change it.
GET /api/last-update — Last scraper run info
GET /api/last-update
Response 200
{
"last_run": "2026-06-26T12:53:30Z",
"last_status": "success: 53/53 zones"
}| Field | Type | Description | Example |
|---|---|---|---|
last_run |
string |
ISO 8601 timestamp of last scrape, or "" if never run |
"2026-06-26T12:53:30Z" |
last_status |
string |
Result of last scrape: "running", "success: N/N zones", "partial: N/N zones (M failed)", or "" |
"success: 53/53 zones" |
POST /api/cache/reset — Invalidate server-side cache
POST /api/cache/reset
X-API-Key: your-secret-key
When API_KEY is configured, this endpoint requires the X-API-Key header. Without a key (or with API_KEY left empty), the endpoint is open.
Response 200
{"message": "Cache invalidated"}| Status | Body | Cause |
|---|---|---|
401 |
{"message":"unauthorized"} |
Missing or wrong X-API-Key |
Per-request invalidation: Add ?invalidateCache=true to any cached GET request to bypass the cache and re-fetch fresh data for that URL. When API_KEY is configured, the X-API-Key header is required.
GET /api/zones?invalidateCache=true
X-API-Key: your-secret-key
GET /* — 404 catch-all
GET /api/nonexistent
Response 404
{"message": "No route matched. Please see the API documentation."}| Field | Type | Description | Example |
|---|---|---|---|
message |
string |
Error message | "No route matched. Please see the API documentation." |
# Seed zones into the database (53 JAKIM zones, embedded in binary)
./scraper seed-zones
# Scrape all zones for a given year (~80s for all 53 zones)
./scraper scrape --year=2026
# Custom DB path
DB_PATH=/tmp/test.db ./scraper seed-zones
DB_PATH=/tmp/test.db ./scraper scrape --year=2025| Prefix | State | Zones |
|---|---|---|
| JHR | Johor | 01–04 |
| KDH | Kedah | 01–07 |
| KTN | Kelantan | 01–02 |
| MLK | Melaka | 01 |
| NGS | Negeri Sembilan | 01–03 |
| PHG | Pahang | 01–03 |
| PLS | Perlis | 01 |
| PNG | Pulau Pinang | 01 |
| PRK | Perak | 01–04 |
| SBH | Sabah | 01–09 |
| SGR | Selangor | 01–03 |
| SWK | Sarawak | 01–09 |
| TRG | Terengganu | 01–04 |
| WLY | W.P. (Kuala Lumpur, Putrajaya, Labuan) | 01–02 |
MIT
Inspired by mpwt-waktusolat/api-waktusolat-x.