Skip to main content
riverbot polls recreation.gov for whitewater permit availability and pushes an ntfy alert when a spot opens inside a target date window — a cancellation/quota-release watcher. It’s a sleep-loop job with no web UI, same shape as Janitorr.

Data source

An unauthenticated monthly endpoint returns per-division, per-day counts:
GET https://www.recreation.gov/api/permits/{permit}/availability/month?start_date=YYYY-MM-01T00:00:00.000Z
The signal is payload.availability[division].date_availability[date].remaining > 0. The endpoint is monthly, so a window spans one call per month. Division ids come from GET /api/permitcontent/{permit} — never guessed.

Targets

Configured in targets.yaml. Division ids are resolved and pinned:
RiverPermitDivisionWindow
Middle Fork Salmon234623377Jun 1 – Jul 20
Main Salmon234622376Jun 20 – Sep 20
Selway234624378Jun 1 – Jul 15
Dinosaur – Green (Gates of Lodore)250014380May 15 – Sep 1
Dinosaur – Yampa (Deerlodge)250014371May 15 – Jul 15
Dinosaur permit 250014 also exposes a 1250014 “Combined Yampa/Green” division — a combined-trip artifact, not one of the two launch segments, so it’s excluded.

Behavior

  • Edge-triggered. State is keyed permit:division:date in ./data/state.json. A date alerts once when it goes open and re-arms when it closes, so a later reopen alerts again.
  • Close-debounce. A reopened date only re-alerts after it has read closed continuously for reopen_debounce_minutes (default 20). This absorbs transient flicker — a scarce date briefly zeroes while two spots sit in someone’s checkout cart — without muting a genuine book-then-cancel. New openings are never delayed; only re-alerts are gated. Transitions are logged (CLOSE/FLAP/ARMED) so real open/closed durations can be measured.
  • Grouped notifications. Newly-open dates are merged into calendar-consecutive runs — one bookable stretch is one notification (title Main Salmon: Sep 8-20), not one ping per day. A gap splits the run; a run that crosses a month boundary reads Aug 30 - Sep 2. Titles carry the river and date span only — no emoji, no remaining-spot count.
  • Cold start alerts. On first run (no state file) every currently-open date inside a window alerts once — the bot proves it’s working by surfacing what’s open now, not just future changes. After that, state suppresses re-alerts until a date closes and reopens.
  • Polling. ~120s interval with ±25% jitter. Permit months are fetched once and shared across that permit’s divisions; calls are spaced a few seconds apart to stay polite to recreation.gov’s Akamai edge. A real browser User-Agent is required.
  • Fault tolerance. A cycle never crashes the loop. A 403/429 block self-alerts (hourly cooldown) — silence there would look identical to “nothing available.”

Operations

  • Image: built locally, pushed to 192.168.1.100:5000/riverbot:latest. Rebuild with bash scripts/rebuild-images.sh riverbot.
  • Logs: docker service logs riverbot_riverbot (or Dozzle).
  • Re-baseline: delete riverbot/data/state.json and restart — the next cycle re-alerts every currently-open in-window date.
  • Add a river: resolve its division from permitcontent/{id}, add an entry to targets.yaml, restart.
  • Swap the notifier: notify() in riverbot.py is the only ntfy-aware seam.