Send a Push Notification from a Shell Script to iPhone

Published:2026-11-08

To tell your phone that a command finished, email, a Slack or Discord webhook, Pushover, ntfy, and Nodd all work with one curl call. They differ when the script needs to ask a person and act on the answer. Nodd's nodd ask waits for an approval on iPhone and exits 0 when approved, 10 when rejected, and 11 when nobody answers.

Short answer

If you only need to know that a command finished, every option here is one curl call. Use a Slack or Discord incoming webhook if your team already lives there, or Pushover or ntfy if you also want Android or desktop. If the script needs to ask a person whether to continue and act on the answer, you need the answer to come back to the script. Nodd is built for that: nodd ask waits for an approval and returns the result as an exit code.

Notify or ask

Long builds, backups, data migrations, and training runs don't need you watching the terminal. A push to your phone when they finish or fail lets you do something else. Here the only requirement is that the message arrives.

The next step is a script that needs a decision halfway: "Start the production migration?" or "Deploy to staging or production?" A notification alone is not enough here. The button the person taps has to reach the script, which continues on approval and stops on rejection or silence. No answer must never count as yes.

The five options below are compared on both needs.

Five ways to do it

1. Email (mail, sendmail, curl over SMTP)

The oldest option. If the machine can already send mail, you need no new service.

# mail(1) needs a working mail transfer agent or relay
echo "backup.sh finished" | mail -s "backup done" you@example.com

# Or talk to an SMTP server with curl (email.txt holds To, From, Subject, and the body)
curl smtp://mail.example.com --mail-from bot@example.com \
  --mail-rcpt you@example.com --upload-file email.txt

Setup depends on your environment. Without a working mail server or relay nothing arrives, and messages can land in spam. You rely on your mail app's notifications, so you may not see it right away. There is no way to send an answer back to the script. The body stays on the mail servers along the way and in the mailbox.

2. Slack or Discord incoming webhooks

POST JSON to a URL issued for a channel. Everyone in the channel sees it.

# Slack
curl -X POST -H 'Content-type: application/json' \
  --data '{"text":"make test finished"}' \
  "$SLACK_WEBHOOK_URL"

# Discord
curl -H 'Content-Type: application/json' \
  -d '{"content":"make test finished"}' \
  "$DISCORD_WEBHOOK_URL"

It works as soon as you create the URL, and the phone gets it as a Slack or Discord notification. To receive button clicks in Slack, you need a Slack app with Interactivity turned on and a public Request URL that accepts Slack's POST. Discord webhooks not owned by an application cannot send interactive components. So a webhook alone sends nothing back. Messages stay in the service as chat history.

3. Pushover

A dedicated notification service with apps for iPhone and iPad, Android, and desktop.

curl -s https://api.pushover.net/1/messages.json \
  --form-string "token=$PUSHOVER_APP_TOKEN" \
  --form-string "user=$PUSHOVER_USER_KEY" \
  --form-string "message=make test finished"

Create an application token and use your user key. With priority=2 (emergency), the notification repeats every retry seconds until someone acknowledges it. The response has a receipt you can poll to see whether it was acknowledged, or you can pass a callback URL. What you learn is whether it was acknowledged, not an approve or reject decision. You can attach one supplementary URL.

4. ntfy

An open source notification service. Use the public ntfy.sh server or run your own.

curl -H "Title: Build finished" -H "Priority: high" \
  -d "make test finished" ntfy.sh/your-hard-to-guess-topic

No sign-up: pick a topic name and publish. As the ntfy docs put it, the topic is essentially a password, so choose one that is hard to guess. Servers with access control take an access token in Authorization: Bearer. A notification can have up to three action buttons, and an http button sends an HTTP request to a URL when tapped. You can build a reply flow on that, but you run the endpoint yourself and the script has to get the result from it. By default the server keeps messages for 12 hours. Self-hosting keeps message bodies under your control.

5. Nodd

An iPhone app that receives notifications and questions from scripts, CI, and AI agents. You send with a send key.

curl https://api.nodd.blipdrop.net/v1/messages \
  -H "Authorization: Bearer $NODD_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind":"notice","title":"make test finished"}'

Sign in with Apple in the app, create a channel, and issue a send key. Set kind to approval and the message gets Approve and Reject buttons that need Face ID. You get the answer by reading the message state, through callbacks, or from the events API, and nodd ask turns it into an exit code. Bodies are encrypted to each recipient device's public key and deleted once the device receives them, but the server handles the body before it is encrypted. iPhone only.

Comparison

EmailSetup: depends on your mail server. Answer: none. Body: stays on mail servers and in the mailbox
Slack / Discord webhookSetup: create a URL. Answer: none from the webhook alone (Slack needs an app and a public endpoint). Body: stays as chat history
PushoverSetup: app token and user key. Answer: emergency priority reports acknowledgment through a receipt or callback, with no approve or reject. iPhone, Android, desktop
ntfySetup: just a topic name, or your own server. Answer: an http button can call your URL, but you build the receiver. iPhone, Android, web
NoddSetup: a channel and a send key in the app. Answer: approve, reject, or a chosen option returns to the sender, answered with Face ID. Bodies are encrypted per device and deleted on receipt. iPhone only

For finished-or-failed pings, the tool you already use is fine. If you want Android or desktop too, or want to host the server yourself, Pushover or ntfy fits better. See Pushover and ntfy compared with Nodd for details.

Notify when a command finishes with Nodd

Install the CLI and log in. nodd login shows a QR code. Scan it under Settings > Log in to CLI in the app and allow it with Face ID. In CI, skip the login and put the send key in NODD_KEY.

brew install blipdrop/tap/nodd
nodd login

This example changes the priority on failure. --level is low, normal, or high, which map to the iOS Passive, Active, and Time Sensitive levels. --body - reads the body from standard input. Bodies are limited to 1,000 characters, so send only the end of the log.

#!/bin/sh
./long_task.sh > task.log 2>&1
rc=$?
if [ "$rc" -eq 0 ]; then
  nodd send "long_task finished" --level low
else
  tail -c 900 task.log | nodd send "long_task failed (exit $rc)" --level high --body -
fi

Without the CLI, add "level":"high" to the curl call above. The API answers 202 when it accepts a message, which does not guarantee it reached a device. For a nightly job, being told that an expected report never came is usually more useful, and that is what nodd heartbeat does. See cron job monitoring.

Wait for approval and branch on the result

nodd ask asks for approval, waits, and returns the result as an exit code.

0Approved, or an option was chosen
10Rejected
11Ended without a response (expired, cancelled, or failed)
12Timed out while waiting
1Any other failure

The shortest form is nodd ask "Deploy to production?" && ./deploy.sh, which continues only on approval. To handle each case, capture the exit code. Under set -e, add || rc=$?, or the script exits as soon as the request is rejected.

#!/bin/sh
set -e
rc=0
nodd ask "Start the production DB migration?" --body "Adds a column to the users table" --expires-in 1800 || rc=$?

case "$rc" in
  0)  ./migrate.sh ;;
  10) echo "Rejected" >&2; exit 1 ;;
  11) echo "No response" >&2; exit 1 ;;
  12) echo "Stopped waiting" >&2; exit 1 ;;
  *)  echo "nodd failed" >&2; exit 1 ;;
esac

Standard output gets approved, rejected, the chosen option ID, or a state such as expired. A rejection reason goes to standard error as Note: reason, so standard output stays safe to parse. Pass --option 2 to 4 times to ask for a choice instead of approval.

target=$(nodd ask "Where should this go?" --option staging=Staging --option prod=Production)
./deploy.sh "$target"

--expires-in sets the response deadline in seconds, 3600 by default. The CLI waits until 15 minutes after the deadline. Network failures do not end the wait, and only a recorded response counts as a result. Use --no-wait to send now and nodd wait ID to wait later.

You can also do it with curl and jq. Until someone answers, action_state stays open, and the reply carries Retry-After: 3, so poll every 3 seconds.

id=$(curl -s https://api.nodd.blipdrop.net/v1/messages \
  -H "Authorization: Bearer $NODD_KEY" -H "Content-Type: application/json" \
  -d '{"kind":"approval","title":"Production DB migration","expires_in":1800}' | jq -r .id)

while :; do
  r=$(curl -s "https://api.nodd.blipdrop.net/v1/messages/$id" -H "Authorization: Bearer $NODD_KEY")
  [ "$(echo "$r" | jq -r .action_state)" = open ] || break
  sleep 3
done

# Continue only on approve. No response is never approval
[ "$(echo "$r" | jq -r '.response.option_id // empty')" = approve ] && ./migrate.sh

This loop stops on a network error and does not migrate. The CLI handles those edge cases for you, so nodd ask is usually the safer choice. Every option is listed in the CLI and API docs.

Caveats and limits

FAQ

What is the simplest way to get a phone notification when a command finishes?
If you already use Slack or Discord, POST to an incoming webhook URL with curl. If you want a dedicated app, Pushover, ntfy, and Nodd each take one curl call.
Can a script read which button I tapped?
With Nodd, yes. nodd ask exits 0 on approval, 10 on rejection, and 11 when it ends without a response. With Slack or ntfy you can build it, but you have to run the server that receives the tap.
What happens if nobody answers?
nodd ask exits 11 when the deadline passes and 12 when it stops waiting. Neither is 0, so a command chained after nodd ask does not run.
Does it work on Android?
Nodd is iPhone only. For Android or desktop, Pushover or ntfy is a better fit.

Nodd delivers questions from scripts, CI, and AI agents to your iPhone, and returns your Face ID approval to the program that asked. There is a free plan.

Related guides