nightbell.app · Why · What it watches · Health reports · Alerts · Widget · How it works · Install
Point it at anything that answers over HTTP, at one element on a rendered web page, at a Prometheus server, at your service's own health report, at a GitHub repository, at a bare host by ping or TCP port, or at an IMAP inbox, and it watches it, charts it, and gets loud when it breaks. No server, no account, no middleman. The phone in your pocket does the checking, and the only host it contacts that you did not choose is a connectivity probe you can change or switch off.
I kept missing outages. The monitoring services I tried either wanted a subscription to text me, or sent a notification that looked exactly like a marketing email and got swiped away with one. I wanted the thing that tells me production is down to be impossible to confuse with anything else.
So urgent alerts in Nightbell arrive as a red card that behaves like an incoming call, with a looping alarm that keeps going until you acknowledge it, and it wakes the screen if the phone is locked.
Note
Three buttons, because at 3am you want to decide one thing: Ack silences it, Re-check runs the check again there and then, and the crossed bell mutes that monitor for an hour.
| Kind | What it does |
|---|---|
| Status check | Hit a URL, assert on the status code. Exact, any 2xx, a range, or any response at all. |
| Request and response | Pick the method, set headers and a body, then assert on what comes back. Contains, does not contain, equals, regex, or a JSON field by path. |
| Page element | Loads the real page in an embedded WebView and watches DOM nodes you picked by tapping them. Existence, text, or an attribute. |
| Prometheus | Reads an exporter's metrics, a PromQL query, or an Alertmanager's firing alerts. |
| Health endpoint | Reads a report your service writes about itself: which part is in trouble, the rule that decided it, and what it saw. Signs in the way your own clients do, single sign-on included. |
| GitHub repository | Polls one repository and reports what changed about it: stars, issues, releases, pull requests and comments. |
| Ping | ICMP echo to an IPv4 or IPv6 host, without root. Latency, packet loss, and down when nothing answers. |
| TCP port | Connects to one port and hangs up. For databases, SSH, VPN endpoints, anything with no HTTP to ask. |
| Mailbox | Signs in to an IMAP inbox and tells you what arrived, louder for the senders or subjects you name. Works with Roundcube's server, Gmail and most others. |
Page-element monitors watch any number of nodes per page and resolve them all against one page load, so watching six costs about what watching one costs. The expensive part is booting the WebView, not the assertions. If the thing you want sits behind a cookie banner or an age gate, press through it once in the live preview and every check afterwards arrives on the other side of it.
The repository kind is the odd one out, and worth saying why it belongs here anyway. Every other kind answers whether something is up; this one answers what happened. They share the cadence, the store, the alert policy and the notification plumbing, which is most of a monitor, so the alternative was a second app carrying a second copy of all of it.
Each track switches on its own: every star or only the milestones, issues narrowed by keyword or by author, releases with or without prereleases, pull requests, and comments with bot accounts filtered out. Its history lists what it saw rather than how fast GitHub answered, so a row reads "13 stars, was 12" or "Issue #6 opened" and runs of checks that found nothing collapse into one line. A digest mode holds changes for an hour or a day, for a repository that has grown past the point where a plus one is news.
A personal access token is optional. Without one you get GitHub's 60 requests an hour per IP, which a couple of repositories on a fifteen-minute cadence fit inside comfortably; with one you get 5,000, and an unchanged check answers 304 without spending any of that budget. The token is stored on the device, never logged, never put in a notification, and left out of an export unless you say otherwise.
Setting one up is a four-step wizard: pick a kind, point it somewhere, say what "healthy" means, and choose a cadence. Everything is changeable later.
Monitors that answer one question can be grouped, and a group is one card with one verdict. "Is Nightbell up" was never a question about the website or about the repository, it was a question about both, and the dashboard used to answer it one row at a time with you doing the and-ing. A group says it directly: all 2 operational, or 1 of 2 is down. The worst member decides it, and pausing one member never decides it unless every member is paused, because a pause you set last week must not be able to hide an outage.
The expiry date arrives free with every HTTPS handshake the checker already performs, so Nightbell reads it and keeps it on its own track, measured in days rather than minutes. A certificate about to lapse is the one outage you can see coming, and it is not an outage yet: the site is up and will stay up for days, so this never wakes anyone.
Servers no public CA vouches for are watchable too. Pinned key records the key on the next successful check and requires exactly that key afterwards, which covers a NAS, a homelab box behind a private CA, and a Tor hidden service. It is stronger than trusting the system store rather than weaker: a CA can be talked into issuing for a name it should not, and no amount of that produces the key the monitor already recorded. Certificates from CAs you installed on the phone yourself are trusted as well.
A status code tells you the server answered. It does not tell you that card payments have been failing for four minutes while everything else is fine. Only your service knows that, so the Health endpoint monitor lets it say so.
Your service serves one JSON document: an overall verdict, the parts that make it up, the rule each part was judged by with the value it measured, and a short log of what it saw. The verdict is what pages. The rest is what you read when it does, so at 3am you can tell a real outage from a flaky probe without opening a server log.
The endpoint can stay behind the same sign-in as the rest of your API: a fixed header, basic auth, or single sign-on through your own web app. If the monitor's sign-in lapses it says "Not checking" instead of paging you about an outage that is not there.
The format, the field limits, how responses are read and a drop-in ASP.NET
Core writer are in docs/health-format.md. An
existing ASP.NET health check that answers plain Healthy works before you
write any JSON.
This is the part every phone monitoring app gets wrong, so it is worth being specific about.
Warning
Android will happily let an app think it is alerting you while delivering nothing. Nightbell needs four grants, and there is a screen on first launch that walks you through them.
| Grant | What breaks without it |
|---|---|
| Notifications | Nothing is posted at all. |
| Unrestricted battery | Android can refuse the service that owns the page and its repeat loop. |
| Full-screen alerts | A page on a locked phone cannot wake the screen. It waits on the lockscreen. |
| Do Not Disturb access | Bedtime mode silences urgent pages completely, which is exactly when you want one. |
Two of those show a dialog in place. The other two are Special App Access toggles, and Android exposes no API to request them, so the screen deep-links straight to each toggle and re-checks when you come back. It cannot be one tap and it does not pretend to be. It is four taps instead of hunting three settings sections.
Once they are granted, the alert policy is yours to shape: what counts as down, how it sounds, the haptic pattern, how hard it escalates, and when to stay quiet.
Tip
Urgent pages follow your ringer switch by default, so vibrate mode gets haptics only. There is a setting to override that if you want a pager that answers to nothing.
Outages can also go to a team: Microsoft Teams, Slack, Discord, Google Chat, ntfy, Gotify, Telegram, or any URL with a body you write yourself. Each webhook runs the same threshold and cooldown as the phone, retries when the receiver is down, and can ignore your quiet hours so the channel still hears at 3am. The details are in docs/reference.md.
Worst monitor first, tap a row to open it, tap the cog to reconfigure it. Every piece of the header switches off independently: the mark, the word Nightbell, the "1 of 6 is down" summary, the cog. That is because "make it clean" means different things to different people, and one flag for all four meant losing the summary to keep the branding.
Monitors flow into columns. A widget dragged flat has spare width and no height, so instead of pushing monitors below the fold and counting them in "+4 more", they move sideways:
Columns are chosen from the size the launcher reports, capped by width, because no
number of monitors justifies a column too narrow to read a name in. Below about
150dp per column the trailing latency is dropped so the name keeps its room, and
a widget too short for a footer and a monitor drops the footer rather than
clipping both. Columns: Auto in the widget settings, or pin it to 1, 2 or 3.
One CheckEngine runs a check, folds the result into persisted state, and
decides whether to interrupt you. Six alert tracks come off that, deliberately
separate, so a bug in Nightbell never gets reported as your website being down,
and a certificate expiring next Tuesday never gets reported as an outage tonight.
flowchart LR
M["Monitors<br/>HTTP · request · page element · Prometheus · health report · repository · ping · TCP port · mailbox"] --> E["CheckEngine"]
E -->|folds each result| DB[("State<br/>one JSON doc")]
E --> A1["Down and recovery"]
E --> A2["Degraded on latency"]
E --> A3["Urgent paging"]
E --> A4["Checker health"]
E --> A5["Certificate expiry"]
E --> A6["Repository activity"]
A3 --> PG["Full-screen red page<br/>looping alarm until you ack"]
style A3 fill:#2F6BFF,stroke:#2F6BFF,color:#ffffff
style PG fill:#FF4D57,stroke:#FF4D57,color:#ffffff
style DB fill:#11151f,stroke:#2F6BFF,color:#ffffff
State is one JSON document in DataStore, which makes the whole store trivially exportable. Checks run on WorkManager by default, or on a foreground service if you turn on strict mode and want a cadence Android will not batch away.
The longer version
docs/reference.md has the full account: the four tracks and why the checker's
health is one of them, count-based sample retention, the certificate track, the
light-theme re-pick, and the reasoning behind the awkward parts. It is the file
to read before changing anything load-bearing.
Listed since 13 August 2026. Take this one if you have the choice: it is the only channel that updates itself.
It is also a reproducible build. F-Droid compiles Nightbell from this source on their own machines, compares the result against the APK attached to the GitHub release, and publishes mine only because the two match byte for byte. So the F-Droid download and the direct download are the same file with the same signature: either one updates the other, and you can move between them without uninstalling and losing your monitors. What it costs to keep that true is docs/FDROID.md, and the choice is one directional, so read it before changing anything a build depends on.
Point Obtainium at the repository and it watches Releases for you:
https://github.com/riveerxd/nightbell
nightbell.app/download redirects to the
current release, so it is the link to scan off a phone or paste somewhere a
versioned URL would rot. Every build is also in Releases
and in artifacts/ in this repo:
curl -fL -o nightbell.apk https://nightbell.app/download && adb install -r nightbell.apkThat reads the redirect rather than naming a version, so it does not go stale.
The version it resolved to is in the filename inside artifacts/ if you want the
file by name instead.
Nightbell is not on IzzyOnDroid and not on Google Play. Play is not a "not yet":
the foreground service declares specialUse rather than dataSync, because
dataSync is capped at six hours a day from API 34 and that cap would quietly
break the one guarantee strict mode exists to make. Getting it through review
would mean justifying the subtype.
Important
3.0.0 does not update a 2.x install. The app was called Pulse until 3.0.0
and its application id moved with the name, from me.river.pulse to
me.river.nightbell. Android identifies an app by that id, so 3.0.0 installs
beside the old one with an empty data directory. No signing key or manifest
setting changes that.
To carry your monitors across: export the JSON from Settings in 2.4.3, install
3.0.0, import it, then uninstall Pulse. Placed widgets and your notification
channel settings do not survive, because a launcher stores a widget provider as
a fully-qualified ComponentName and channel grants belong to the old package.
Full detail in docs/MIGRATION_3.0.0.md.
A direct APK is signed with my own key, so Play Protect will ask you to confirm the first time. 3.0.1 onward updates in place as usual.
3.0.1 changed the signing key. The old certificate's subject read
CN=Pulse Monitor, and a subject cannot be edited without issuing a new
certificate. Android refuses an update signed by a different key, so if you
installed 3.0.0 you have to uninstall it before installing 3.0.1. Export first.
Needs JDK 17 and an Android SDK with API 36. local.properties wants
sdk.dir=/path/to/Android/Sdk.
./gradlew :app:assembleDebug # debuggable
./gradlew :app:assembleRelease # minified, needs keystore/keystore.properties
./gradlew :app:testDebugUnitTest # 1323 JVM testsRelease builds are signed from keystore/keystore.properties, which is
gitignored along with the key itself. Without it the release task still builds,
just unsigned.
Tests: 1323 JVM + 586 on-device
1323 JVM tests cover the pure logic: the alert state machines, escalation, quiet-hours arithmetic, assertions, the latency baseline, backup round-trips, and the widget's column arithmetic, which decides whether a monitor is visible at all and can otherwise only be exercised by dragging a widget around a home screen. 586 instrumented tests cover the parts that need a real Android: notifications, channels, the foreground service, the WebView element checker, widgets, the launcher icon's transparency, and a suite that drives a genuine connection-refused outage all the way to a red page on screen.
# one class at a time, the emulator does not enjoy the whole suite at once
adb shell am instrument -w -e class me.river.nightbell.UrgentPageEndToEndTest \
me.river.nightbell.debug.test/androidx.test.runner.AndroidJUnitRunnerThe urgent path has a habit of breaking in ways only a device shows: the alarm taking half a minute to stop after acknowledging, a custom notification layout getting squashed vertically, colorisation being silently dropped. None of that is visible from reading the code, so those cases got tests that were checked against the broken version first.
Important
Quiet hours still suppress urgent pages unless you turn on the bypass. That default is wrong and is the next thing I want to fix properly, with a migration rather than a flipped constant.
- Element checks need a WebView per page load. They are the slow kind of check.
- Repository monitors share GitHub's unauthenticated budget of 60 requests an hour per IP until you add a token. Several repositories on a short cadence will find the ceiling.
- Strict mode costs battery and a permanent notification. That is Android's price for a real cadence, not a design choice.
- No CI. The test commands above are run by hand.
Apache 2.0, see LICENSE. Same licence as everything it depends on (androidx, Compose, OkHttp, kotlinx), so there is nothing awkward to reason about if you want to fork it or lift a piece of it.
Do what you like with it. Keep the notice, and say what you changed.
Built to be impossible to confuse with a marketing email. If it saved you an outage, a ⭐ is appreciated.






