Moving your apps from Prowl to Nowlark
There are two ways, and the fast one takes a minute.
The fast one: change the address#
Nowlark answers Prowl's own API. Point the sender at https://nowlark.com and paste a
Nowlark key where it asks for a Prowl key, and it works: same application, event,
description, priority and url, same XML response, same −2…2 priority scale. See
Prowl's own API.
This is the route for any Prowl sender whose server address you can change. It is not second class: a notification arriving this way goes through the same code as a native one.
Not every app with Prowl built in. Many of them post to a
hardcoded api.prowlapp.com (checked in their source, 2026-09-18), so there is no address to
change. If yours has an ntfy notifier, use that instead: server https://nowlark.com, any topic,
and your Nowlark key as the access token. See ntfy's publish API.
Prowl's own API#
For scripts and senders whose server address you can change. Nowlark answers Prowl's own endpoints, so a sender like that needs only a new address and a Nowlark key.
Point it at https://nowlark.com and paste a Nowlark key where it asks for a Prowl key.
That is the whole change.
GETPOST /publicapi/add#
Form-encoded or query string, exactly as Prowl accepted both.
| Prowl field | Becomes | Notes |
|---|---|---|
apikey |
The credential |
Your nlk_… key. Up to five, comma separated, as Prowl allowed |
application |
source |
256 |
event |
title |
1024 |
description |
body |
10000 |
priority |
priority |
Same scale, −2…2. The numbers in your scripts still mean what they meant |
url |
url |
512. A web page or an app's link; links that could run code, such as javascript: and file:, are refused (see Links) |
providerkey |
(none) |
Accepted and ignored |
curl -sS https://nowlark.com/publicapi/add \
-d apikey=nlk_… \
-d application=Backup \
-d event=Finished \
-d description="412 GB in 38 minutes." \
-d priority=1
The response is Prowl's XML, because that is what the clients parse:
<?xml version="1.0" encoding="UTF-8"?>
<prowl>
<success code="200" remaining="999" resetdate="1789665600" />
</prowl>
GETPOST /publicapi/verify#
Checks a key. Same XML, and it does not spend one of your hourly calls.
Status codes#
Prowl's, not HTTP's obvious ones. A client written against Prowl branches on exactly these:
| Code | Meaning |
|---|---|
200 |
Accepted |
400 |
Bad request: no application, no event or description, a non-http url, priority out of range |
401 |
Unknown or missing key. One bad key in a comma-separated list refuses the whole call and sends nothing |
406 |
The key's 1000/hour is spent. Not 429: Prowl used 406 and clients back off on it |
500 |
Something unexpected |
What is deliberately different#
- A non-http
urlis refused. Prowl passed anything through. verifyis free. Prowl's spent a call, which is why its own docs told you not to call it before every send.- A muted source returns success. It was accepted and recorded; the sender does not get a vote on whether you want to hear from it, and an error would make a script retry something that never failed.
providerkeydoes nothing. It existed so Prowl could revoke one third-party application across every user at once, which needs a registry and a company to run it.
The two APIs are one implementation#
A notification sent this way is indistinguishable from a native one by the time it reaches a
phone: same validation, same rate limit, same dedupe, same muting, same storage, same fan-out.
Prowl calls reach the same code /v1/notify uses. Nothing
about what a valid notification is, is decided twice.
The compatibility endpoints are not in the OpenAPI document. That is deliberate: the schema describes what you should write against, and this is what you should be migrating away from.
The thorough one: rewrite what you own#
Worth doing for the senders that are yours: the native API has dedupe, grouping, per-source silencing, free key checks and structured outcomes. The field names change, the response becomes JSON, and one shared client replaces your hand-written senders.
The field mapping#
| Prowl | Nowlark | Changed? |
|---|---|---|
apikey (form field) |
X-Nowlark-Key header |
Moved out of the body |
application |
source |
Renamed. Keep each script's own name here, not one shared name: each becomes its own row in Sources (why) |
event |
title |
Renamed |
description |
body |
Renamed |
url |
url |
Same. A web page or an app's link; links that could run code, such as javascript: and file:, are refused |
priority |
priority |
Same scale, −2…2. Numbers in your scripts still mean what they meant |
providerkey |
(none) |
Gone. There is no provider concept |
(none) |
group_key |
New: stack related alerts |
(none) |
dedupe_key |
New: a retrying cron job cannot double-notify |
form-encoded POST |
JSON POST |
|
XML response |
JSON response |
|
1000/hour per key |
1000/hour per key |
Same |
verify spends a call |
/v1/key/check does not |
Spends no call |
Field limits are unchanged (256 / 1024 / 10000 characters), so any payload that fitted Prowl fits Nowlark.
Step 1: get a key#
In the Nowlark app: Settings › Connect an App › Create Your Key. Name it after the thing that will use it. The key is shown once; copy it then.
One key is free. With the Upgrade Pass you can give each script its own: the rate limit is per key, so one runaway loop cannot silence the rest, and replacing a key that leaked does not disturb anything else.
Step 2: put it where your scripts already look#
If your scripts read PROWL_API_KEY from their environment (a crontab's environment block, a
launchd plist, a .env file), put NOWLARK_API_KEY beside it:
# crontab -e, beside the existing PROWL_API_KEY line
NOWLARK_API_KEY=nlk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
A launchd agent does not inherit cron's environment, so a script that runs both ways needs the
key in both places. The Python client below reads it back out of crontab -l when the
environment does not have it, so a crontab entry alone is enough for it.
Keep PROWL_API_KEY there until the move is finished and checked.
Step 3: one client instead of many senders#
If you have several scripts that each grew their own "send a notification" function, they have almost certainly drifted: different timeouts, different error handling, one that sends the URL and one that forgot. Replace them with one shared client.
nowlark.py is standard library only, with no requests, so it drops into
a script that deliberately uses urllib without adding a dependency. For anything that is not
Python there is nowlark, a POSIX shell script.
curl -O https://nowlark.com/clients/nowlark.py
Step 4: check before you cut over#
NOWLARK_API_KEY=nlk_… python3 nowlark.py --check
Prints the key's label and remaining calls, and spends nothing. Then send one for real:
python3 nowlark.py "Migration test" "Nowlark is live" \
"If you can read this, cut the rest over." --priority 1
delivered in the output is the number of your devices Apple accepted it for. If it is 0 with
devices: 0, no phone has registered yet: open the app once.
Things that behave differently, on purpose#
- A duplicate is a success.
dedupe_keyreturns200with"duplicate": true. Do not treat it as a failure and retry. - A muted source is a success.
"muted": true, stored in history, not pushed. The user muted it; the sender does not get a vote. delivered: 0is not an error. It means no device accepted it, usually because none is registered yet. The notification is still in history and appears when the app is opened.- Non-http URLs are refused. Prowl accepted anything. A
urlis something a phone will open on a tap, so a remote sender does not get to choose the scheme.