Send Push Notifications From Rust
A push notification from Rust is one reqwest call and a serde struct. No SDK, no certificates. Ideal for the long-running binary you walk away from.

Rust is the language of things that run for a long time without supervision. Batch importers, indexers, media pipelines, the release build that takes eleven minutes. Exactly the sort of process where you start it, walk away, and come back far later than you needed to.
Sending yourself a notification when it ends takes one HTTP call. No SDK, no APNs certificates, no async runtime you were not already using.
What you need
- An app key from TheNotificationApp, which takes a minute to create
reqwest, or any HTTP client you already have- The iOS app installed and signed in with the same Apple ID
Step 1: Dependencies
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }The json feature on reqwest is the one people forget. Without it there is no .json() method on the request builder and the error message points at the method rather than the missing feature flag.
Step 2: The payload and the call
use serde::Serialize;
const NOTIFY_URL: &str = "https://thenotification.app/api/sendNotification";
#[derive(Serialize)]
struct Notification<'a> {
title: &'a str,
body: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
link: Option<&'a str>,
}
async fn notify(title: &str, body: &str, link: Option<&str>) -> anyhow::Result<()> {
let app_key = std::env::var("TNA_APP_KEY")?;
let response = reqwest::Client::new()
.post(NOTIFY_URL)
.header("app_key", app_key)
.json(&Notification { title, body, link })
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("notification failed: {}", response.status());
}
Ok(())
}The skip_serializing_if attribute is doing real work. Without it, link: None serialises to "link": null, and sending an explicit null for an optional field is a good way to get a 400 back from an API that expected the field to be absent.
Note also that reqwest sets Content-Type: application/json for you when you use .json(), so there is no need to set it by hand.
Only title and body are required. link makes the notification tappable and there is an optional image too. The full list is in the API reference.
Step 3: Wire it to the end of the job
use std::time::Instant;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let started = Instant::now();
match rebuild_index().await {
Ok(count) => {
let secs = started.elapsed().as_secs();
notify(
"Index rebuild finished",
&format!("{count} documents in {secs}s"),
Some("https://dashboard.example.com/search"),
)
.await
.ok();
}
Err(err) => {
notify("Index rebuild FAILED", &format!("{err}"), None)
.await
.ok();
return Err(err);
}
}
Ok(())
}Two deliberate choices here. The .ok() discards the notification's own result: if the push fails, that must not become the error your job reports, because then a network blip in your alerting looks like a failed index rebuild. And the error branch returns the original error after notifying, so you keep the non-zero exit code that everything else depends on.
Catching a panic
The version above handles a returned Err. It does not catch a panic, and in a long-running job a panic is exactly the case you most want to hear about.
A hook covers it:
fn install_panic_notifier() {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let msg = info.to_string();
// Blocking send: the process is already going down, so spawning onto
// the async runtime here is not reliable.
let _ = reqwest::blocking::Client::new()
.post(NOTIFY_URL)
.header("app_key", std::env::var("TNA_APP_KEY").unwrap_or_default())
.json(&serde_json::json!({
"title": "Job panicked",
"body": msg.chars().take(180).collect::<String>(),
}))
.send();
default(info);
}));
}This needs the blocking feature on reqwest. The blocking client is the right call inside a panic hook: the runtime may already be unwinding, so scheduling async work is not something to rely on. Call install_panic_notifier() once at the top of main.
Without an async runtime
If your binary is not async, do not add tokio just for this. The blocking client is a couple of lines:
fn notify_blocking(title: &str, body: &str) -> anyhow::Result<()> {
reqwest::blocking::Client::new()
.post(NOTIFY_URL)
.header("app_key", std::env::var("TNA_APP_KEY")?)
.json(&serde_json::json!({ "title": title, "body": body }))
.send()?
.error_for_status()?;
Ok(())
}error_for_status() turns any 4xx or 5xx into an Err, which saves the manual status check.
Proving it works before you need it
The worst time to discover a typo in your app key is inside the panic hook of a job that just died at 4am. Add a hidden flag that sends a test notification and exits:
// somewhere near the top of main
if std::env::args().any(|a| a == "--notify-test") {
notify("Test from the indexer", "If this arrives, alerting works.", None).await?;
println!("sent");
return Ok(());
}Then cargo run -- --notify-test is a one-second check that the key is right and the network path works. Run it once after every deployment to a new environment, because "the binary can reach the internet" is an assumption that fails quietly in containers.
It is also worth an integration test that does not hit the network at all, just to keep the payload shape honest:
#[test]
fn omits_link_when_absent() {
let json = serde_json::to_string(&Notification {
title: "t",
body: "b",
link: None,
})
.unwrap();
assert!(!json.contains("link"), "None link must not serialise");
}Reading the errors
Three status codes are worth handling by name:
- 401: the
app_keyheader is missing or wrong. Usually an environment variable that is not set in the environment the binary actually runs in, which is not the same as your shell. - 400:
titleorbodyis missing. With a typed struct this is nearly impossible, which is a decent argument for the struct over an ad-hocjson!macro. - 429: you are out of quota. The body tells you the limit and how many you have sent.
The honest part
The free tier is 100 notifications for the lifetime of the account, not per month. For "tell me when the nightly import finishes" that is months of use. For a notification inside a loop over two million documents it is about four seconds, so notify on the job, not on the unit of work.
Pro is $2.99 a month for 1,000. And this delivers to iPhone only, because it rides Apple's push service.
Same call, other languages
Nothing here is Rust-specific beyond the types. The identical request from Go is in sending push notifications from Go, and the concept behind it is in what a push notification API actually is.
Grab a free key at thenotification.app and stop checking on a binary that finished twenty minutes ago.



