At a glance
- Publish with a POST to
ntfy.sh/<topic>; subscribe to the same topic in the Android, iOS or web app. - Headers set the
Title,Priority(1 to 5),Tagsand aClickURL. - ntfy.sh caches messages for 12 hours and allows 250 a day per visitor.
- Anyone who knows a topic name can read and publish to it.
Send your first notification
Install the ntfy app on Android or iOS, or open the web app, and subscribe to a topic name. There is no signup: a topic exists the moment you use it, so your server or laptop only has to publish to the same name.
curl -d "Backup done" ntfy.sh/your-topicThe message arrives on every device subscribed to that topic.
Add a title, priority, tags and a click link
Plain text works, but HTTP headers control how the notification looks and behaves. Pass them with -H when you publish.
Titlesets a heading above the message.Prioritygoes from 1 to 5, or use the namesmin,low,default,highandurgent.Tagstakes emoji shortcodes such aswarningorwhite_check_mark.Clickis a URL that opens when you tap the notification.
curl \
-H "Title: Backup finished" \
-H "Priority: high" \
-H "Tags: white_check_mark,floppy_disk" \
-H "Click: https://example.com/backups/2026-10-30" \
-d "1,284 files, 3.2 GB in 42 minutes" \
ntfy.sh/your-topicSend from Python
You don't need an SDK. A POST with requests does the same thing as curl: the message is the body and the options are headers.
For unattended scripts, wrap the job in try/except so a crash sends an urgent notification with a warning tag, while a normal run sends the regular message. Set timeout=10 so a network hiccup never hangs the script, and catch errors inside the helper so a failed push never hides the job's own traceback.
import os
import requests
TOPIC_URL = f"https://ntfy.sh/{os.environ['NTFY_TOPIC']}"
def push(message, title, priority="default", tags="bell"):
try:
requests.post(
TOPIC_URL,
data=message.encode("utf-8"),
headers={"Title": title, "Priority": priority, "Tags": tags},
timeout=10,
)
except requests.RequestException as exc:
print(f"ntfy push failed: {exc!r}")
try:
rows = run_import()
except Exception as exc:
push(repr(exc), "Import failed", priority="urgent", tags="warning")
raise
else:
push(f"{rows:,} rows imported", "Import finished", tags="white_check_mark")Attach a log file and check what arrived
Send a file with curl -T and name it with the Filename header. The notification reads "You received a file: backup.log" and links to the download; on ntfy.sh attachments expire after 3 hours.
curl -T backup.log -H "Filename: backup.log" -H "Title: backup.sh failed" ntfy.sh/your-topic
curl -s "ntfy.sh/your-topic/json?poll=1"To see what reached a topic, poll it: ?poll=1 returns the cached messages as one JSON object per line, then closes the connection. Without poll=1 the connection stays open and prints each new message as it arrives, which makes a small subscriber for another server.
Keep your topic private
On ntfy.sh, topics are public. Anyone who knows the name can subscribe and read your alerts, or publish their own. As the ntfy FAQ puts it, without access control the topic name is your password, and a randomly generated name is as good as a good password.
Don't use a guessable name. Generate a long random one:
python -c "import secrets; print(secrets.token_urlsafe(18))"For real access control, use a server with users and access tokens, or run your own. Protected topics accept a username and password through Basic auth, or an access token in an Authorization: Bearer tk_... header.
Self-host ntfy
ntfy is open source and runs with one Docker command. By default it keeps messages in memory for 12 hours, so they are lost when the server restarts. Mount a volume and pass --cache-file to keep them on disk.
docker run -p 80:80 -it binwiederhier/ntfy serve
docker run -p 80:80 -v /var/cache/ntfy:/var/cache/ntfy -it \
binwiederhier/ntfy serve --cache-file /var/cache/ntfy/cache.dbOn your own server you set every limit yourself.
ntfy.sh free limits
The public ntfy.sh server is free to use, and paid ntfy Pro tiers exist if you need more. Here is how it compares to a self-hosted server's defaults:
| Limit | ntfy.sh (public server) | Self-hosted default |
|---|---|---|
| Messages per day | 250 per visitor | No daily cap; request limits apply |
| Message size | 4,096 bytes; longer becomes an attachment | 4,096 bytes, configurable |
| Request rate | Burst of 60, then 1 every 5 seconds | Burst of 60, then 1 every 5 seconds |
| Message cache | 12 hours | 12 hours in memory, unless you set a cache file |
| Attachment expiry | 3 hours | 3 hours |
| Emails | 5 per day | Burst of 16, then 1 per hour |
For long Python jobs, a live progress link in the Click header turns the notification into a one-tap status check.
FAQ
Is ntfy free?
Yes. ntfy is open source, so self-hosting is free, and the public ntfy.sh server is free within its limits of 250 messages a day. Paid ntfy Pro tiers on ntfy.sh raise those limits.
Are ntfy topics private?
Not on ntfy.sh by default: anyone who knows the topic name can read and publish to it. Without access control the name acts as the password, so generate a long random one.
How long does ntfy.sh keep messages?
ntfy.sh caches messages for 12 hours, so a phone that was offline still receives them when it reconnects. Attachments are deleted after 3 hours.
Does ntfy work on iPhone?
Yes. There is an iOS app next to the Android and web apps, though the ntfy FAQ notes that the iOS app lags behind the Android one.
How do I open a link when I tap an ntfy notification?
Send a Click header with the URL when you publish. Tapping the notification on your phone then opens that URL.
Check on long runs from your phone
Change one import and every tqdm bar prints a link you can open on any device. Get a Discord or Slack message when the run finishes, fails or stalls.
pip install tqdm-liveSources
- ntfy docs: Publishing, headers and limitations docs.ntfy.sh
- ntfy docs: Configuration, cache and rate limits docs.ntfy.sh
- ntfy docs: Subscribe via API docs.ntfy.sh
- ntfy docs: Installation and Docker docs.ntfy.sh
- ntfy docs: FAQ docs.ntfy.sh