Back to Blog

Reliable push alerts on iPhone from a self-hosted stack

Reliable push alerts on iPhone from a self-hosted stack

Your Grafana alert fires at 3 a.m. Your server logs show the webhook call succeeded. Your iPhone stays silent. Self-hosted monitoring is good at detecting failures. Getting the message onto a phone is where most homelab setups break down.

This guide explains why iPhone push from a self-hosted stack is hard, what a reliable path looks like, and how to connect Grafana, Uptime Kuma, n8n, cron jobs, and shell scripts. The first working alert takes about five minutes.

Why iOS push breaks in self-hosted setups

Only APNs can reach an iPhone

Every push to an iPhone goes through Apple Push Notification service (APNs). Your server cannot open a connection to the phone and send data directly. The phone keeps one long-lived connection to Apple. Your server sends each message to APNs over HTTP/2, authenticated with a key from your Apple developer account. APNs then delivers it down the phone's existing connection.

That means your stack needs an APNs-capable sender in the middle. The phone never polls your server, so a plain webhook endpoint on your LAN has nothing to deliver to. For a deeper look at the delivery path, see how push notifications reach an iPhone.

Running your own sender is more work than it looks

You can talk to APNs yourself. You need a signing key (a .p8 file for token auth, which does not expire yearly, or a TLS client certificate per app, which does), a registered app, a way to store device tokens, and code that handles APNs errors and retries. You also need an iOS app that registers the device and shows the alert. Each piece is manageable alone. Keeping all of them working through key rotation and Apple policy changes is the real cost.

Most self-hosters need the result, not the plumbing. A hosted service that already runs the APNs side lets your stack stay simple: one HTTP request per alert.

Focus, mute and interruption levels

iOS decides how loudly to deliver each notification based on its interruption level, your Focus modes and the app's settings. Three things matter for alerts:

  1. Notifications must be allowed for the app. Check this first in iOS Settings. A muted app will never show your alert, no matter how the server behaves.
  2. Time-sensitive notifications can break through Focus. In Pocket Alert, high messages arrive as time-sensitive. Lower levels wait for the Focus to end.
  3. Critical alerts ignore the mute switch and Do Not Disturb. They need a special entitlement that Apple grants after review. Pocket Alert has it: critical messages on paid plans play a sound even when the phone is muted, as long as you allowed Critical Alerts for the app. If you turned that permission off, they arrive as time-sensitive instead.

The priority section below covers how to set the level.

How a reliable setup looks

Your stack detects a problem and sends one HTTP request. A hosted service takes it from there:

Grafana / Uptime Kuma / n8n / cron + CLI
        │  HTTPS (API call or webhook)
        ▼
Pocket Alert  ──►  APNs  ──►  iPhone (Pocket Alert app)

The division of work looks like this:

Your stack doesPocket Alert does
Detects the failure and decides what mattersReceives the request and checks your API key or webhook URL
Formats the text, or sends its own JSONMaps fields, sets the priority level, and delivers to APNs
Sends the event once, when it happensPushes to every device on the account, or to the device you name
Keeps its own logsKeeps message history and a request log for each webhook, so you can see what arrived and what failed

Send your first alert with curl

Sign up at pocketalert.app/signup, open API keys, and click Create New Key. The key is shown once, so copy it somewhere safe.

Store it in an environment variable and send a message:

export PA_KEY="YOUR_API_KEY"

curl -X POST "https://api.pocketalert.app/v1/messages" \
  -H "Token: $PA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Backup finished",
    "message": "nightly-backup on nas01 completed in 14m",
    "level": "high"
  }'

A successful call returns JSON with tid, title, message, application, device, and created_at. Keep the tid if you want to match a push to a log line later.

Three things trip people up:

  • Set the Content-Type. The API accepts JSON or multipart/form-data. It rejects application/x-www-form-urlencoded, which is what curl -d sends when you leave the header out. Use -H "Content-Type: application/json" with a JSON body, or -F for form fields.
  • title and message are required. Everything else is optional.
  • Omit device_id to reach every device on the account. Add it when you have more than one phone and want a specific one.

Send alerts from the command line

The Pocket Alert CLI is the fastest way to wire alerts into existing scripts. Install it with the shell script, Homebrew, or Go:

curl -fsSL https://raw.githubusercontent.com/PocketAlert/cli/main/install.sh | sh
# or
brew install pocketalert/tap/pocketalert
# or
go install github.com/PocketAlert/cli/cmd/pocketalert@latest

Run pocketalert auth once and paste your key. The key is stored in ~/.pocketalert/config.json, readable only by you. On servers, containers, and CI, set POCKETALERT_API_KEY instead.

Send a message:

pocketalert send "Deploy finished" "v2.4.0 is live" --level high

Pipe command output straight into an alert:

df -h | pocketalert send "Disk report" --level default

Or wrap a command with exec. It runs the command as usual, then sends one push with the exit status, the duration and the last lines of output, and passes the exit code through:

pocketalert exec -t "Database dump" -- ./dump.sh

Use -q to print only the message ID, which is handy for scripting. Add --json to any command for machine-readable output.

Webhooks with field mapping

When your source already sends JSON, such as Grafana, Uptime Kuma, or a custom service, you don't need to write a sender. Create a webhook in the console at Webhooks, and Pocket Alert gives you a receive URL like https://p4a.me/wh/1234abcd. The slug is unguessable, and each URL is scoped to one webhook and one application. Point your source at that URL with POST.

The push arrives titled "Webhook" plus the webhook's name, so name it after the source, for example Grafana. The message template pulls values from the incoming JSON with GJSON paths:

  • %object.field% for nested fields
  • %items.0.name% for array items
  • %users.#% for a count
  • * as the whole template sends the entire body

For example, a template for an incoming payload like this:

{
  "service": "api-gateway",
  "check": "tls-expiry",
  "state": "warning",
  "detail": "certificate expires in 6 days"
}

could be:

[%state%] %service%: %check%
%detail%

A placeholder whose path is missing renders as an empty string, so a typo shows up as a gap in the text, not as an error.

Before you write a template, capture the real payload. Open the webhook tester, copy the test URL, and point your source at it. The tester shows the method, headers, and body, so you can write paths against what your source actually sends. Do not send production secrets to a public test URL. Anyone who knows the URL can read what arrives there.

Priority works in two ways. Set a default level on the webhook, or include a top-level level field in the payload, which overrides the default. Valid values are silent, low, default, high, and critical, or the numbers -2 to 2. An invalid level is ignored, and the webhook's default applies.

Integrations

Grafana alerts

In the console, create a webhook named Grafana with this message template:

[%status%] %commonLabels.alertname% (%commonLabels.severity%)
%alerts.0.annotations.summary%

Copy its receive URL. In Grafana, go to Alerting → Contact points → Add contact point, choose the Webhook integration, and set:

Integration: Webhook
URL:         https://p4a.me/wh/1234abcd
Method:      POST

Attach the contact point to a notification policy so matching alerts route to it, then press the contact point's Test button. A firing alert called HighCPU with a severity="critical" label shows up as [firing] HighCPU (critical). When it clears, the same push arrives with resolved in the brackets.

Three notes. The severity part is empty unless your rules set that label. The template reads only the first alert in a group (alerts.0), so if one notification bundles several alerts, you see the first summary only. And the pending period on each rule (for example, 5 minutes) is the cheapest way to stop a flapping metric from waking your phone every minute. The Grafana guide covers more fields.

Uptime Kuma

Uptime Kuma sends a webhook on each status change. Add a notification of type Webhook, set Post URL to your receive URL, and keep the JSON preset as the request body.

In Pocket Alert, use %msg% as the template. msg is the one field present in every request Uptime Kuma sends, including certificate warnings and test messages, and it already reads like "[Website] [🔴 Down] timeout of 4000ms exceeded". Set the webhook's default level to high.

With the preset body, recoveries arrive as loud as outages, because Uptime Kuma does not add a level field on its own. The Uptime Kuma guide shows a custom body that sends high for down and low for up.

n8n

In an n8n workflow, add an HTTP Request node after the step that detects a problem:

  • Method: POST
  • URL: https://api.pocketalert.app/v1/messages
  • Authentication: Generic Credential Type, then Header Auth. Name Token, value your API key.
  • Send Body: on, Body Content Type JSON, Specify Body Using JSON, with an expression that builds the whole object:
{{ JSON.stringify({
  title: 'Disk almost full on ' + $json.host,
  message: $json.detail,
  level: 'high'
}) }}

JSON.stringify escapes quotes and line breaks in the values, which plain "{{ $json.detail }}" inside a JSON string does not. For failures across all workflows, set up an error workflow that starts with an Error Trigger: the failed workflow's name is in $json.workflow.name and the error in $json.execution.error.message. The n8n guide has a ready workflow to import.

Scheduled and recurring checks

Cron jobs fail silently, which is the worst time to miss an alert. Put the alert in the job itself:

POCKETALERT_API_KEY=YOUR_API_KEY
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * pocketalert exec -t "Nightly backup" -- /opt/scripts/backup.sh

Cron does not load your shell profile, so the two lines at the top give the job the key and a PATH that includes the CLI. A successful run arrives as "✓ Nightly backup" at the default level. A failed one arrives as "✗ Nightly backup (exit 1)" at high, with the last lines of output in the message. Add --on failure if you only want to hear about failures.

exec tells you when a job fails, not when it never runs. For that, pair it with a dead man's switch, as in the cron job monitoring guide.

If you're not sure about a schedule, paste it into the free cron expression tool. It decodes each field into plain English and lists the next run times. The next-run times use your browser's time zone, which may differ from the server's.

Priority levels and critical alerts

Five levels are available, from quiet to loud:

LevelValueOn iPhoneTypical use
silent-2No sound, straight to Notification Center"Resolved" events, logs you may read later
low-1Passive, does not light up the screenInformational
default0Normal notification with soundRoutine results, such as a finished backup
high1Time-sensitive, breaks through FocusNeeds attention soon
critical2Critical Alert, sounds even when mutedNeeds attention now. Paid plans only

On the free plan, a critical message is delivered at high. It still arrives immediately as time-sensitive, but it respects the mute switch.

A reasonable mapping for a firing alert and its resolution:

  • firing: high, or critical for the few things worth waking up for
  • resolved: silent

Set this with the webhook's default level, or with a level field in the payload when your source can send one.

Checklist: make sure your alert arrives

  1. Allow notifications for the Pocket Alert app in iOS Settings, including Time Sensitive Notifications and, on a paid plan, Critical Alerts.
  2. Test with Focus on. Turn on the Focus mode you use, send a high test with the curl command above, and confirm it gets through. If it doesn't, check the app's notification settings for that Focus.
  3. Confirm the call reached Pocket Alert. API messages show up in the message history in the app and the web console. Webhook calls, including rejected ones, show up in that webhook's request log. If the call is there, the problem is on the phone. If it isn't, the problem is in your source.
  4. Check your source's logs for the outgoing request: URL, status code, and response body.
  5. Watch the daily limit. The free plan allows 50 notifications a day. A flapping check can use that up in an hour, so tune thresholds and pending periods before you scale up. pocketalert usage shows how many you have left today.

If an alert still doesn't arrive, work through the steps in order: source log, then Pocket Alert history, then level and device settings. This order isolates the failing hop quickly.

FAQ

Can I send push notifications to an iPhone without my own Apple developer setup?
Yes. A hosted service that already connects to APNs handles the Apple side. You send an HTTP request with your API key, and the service delivers it to its iPhone app. Running your own sender requires signing keys, an app registration and an iOS app of your own.

Why do my self-hosted alerts arrive late or not at all?
Usually one of three things: notifications are not allowed for the app, the active Focus holds back the alert's level, or the request never reached the service. Check the message history or webhook log first to see which one applies.

Will alerts get through when Focus is on?
high messages arrive as time-sensitive, which can break through Focus once notifications are allowed. critical messages on paid plans go further and sound even when the phone is muted. Lower levels wait. Test your own Focus setup before you rely on it.

Is there a free plan?
Yes. The free plan includes 50 notifications a day, 1 device, 1 application, and 1 webhook. Critical messages are delivered at high priority on the free plan.

Next steps

You can have your first iPhone alert in about five minutes:

  1. Sign up and install the app on your iPhone.
  2. Create an API key at API keys.
  3. Run the curl command from this guide.

Once that works, connect one real source: a Grafana contact point, an Uptime Kuma notification, or a cron job. For more homelab sources, see homelab alerts and the Proxmox guide. For other integrations, see webhook notifications and Grafana alerts. If you're comparing options, the alternatives page lays out the differences.

Share this article