Nowlark API

Base URL: https://nowlark.com

Two kinds of credential, and they are not interchangeable. Sending one where the other belongs is refused by name rather than by a blank 401, so the mistake is obvious.

Credential Prefix What it can do Where it comes from
API key nlk_… Send notifications. Nothing else. Settings › API Keys in the app
Account key nla_… Everything: read history, manage keys and devices, delete the account Minted on first launch, kept in the user's iCloud. Scripts never need it.
Forms admin key nfa_… Read and triage one site's form submissions (Nowlark Forms). Nothing else. python forms_admin.py mint <site> on the server; held by that site's server only

A script only ever needs an API key. The rest of this document is mostly about that one endpoint; the account endpoints are documented because the app uses them, not because anything else should.


Sending#

POST /v1/notify#

The whole product. Send it an API key and a notification, it reaches every device on the account.

Auth. Either header works:

X-Nowlark-Key: nlk_…
Authorization: Bearer nlk_…

Body, as JSON:

Field Type Required Limit Meaning
source string yes 256 The sender's name, shown on every notification and listed in Sources. One per script or job, e.g. "Rewards watcher", "Nightly backup". See Choosing a source.
title string one of title/body 1024 The banner's headline.
body string one of title/body 10000 The detail below it.
url string no 512 The link the notification carries. http or https only. See Links.
image string no 512 An image shown on the notification and in the app. https only, because the device fetches it automatically, with no tap. Part of the Upgrade Pass; without one the send is refused 402 upgrade_required.
actions string[] no 3 Buttons on the notification, from open, copy, mute, done. Part of the Upgrade Pass.
copy_text string with copy 512 What the Copy button puts on the clipboard: a code, a URL, anything. Copying happens without opening the app.
priority int no −2…2 See the table below. Defaults to 0.
group_key string no 128 Notifications sharing this stack together on the phone.
dedupe_key string no 128 Send the same value twice and only the first is delivered.
replace_key string no 128 A later notification from the same source with the same replace_key takes this one's place: the older one is removed from the feed and its banner is replaced on the Lock Screen. For a job reporting progress or state. Also ends any repeat the older one had.

Response, 200:

{
  "id": "ntf_5f3c…",
  "duplicate": false,
  "muted": false,
  "delivered": 2,
  "devices": 2,
  "remaining": 997,
  "reset_at": 1789665600.0
}
  • delivered / devices: how many of the account's registered devices Apple accepted it for. delivered: 0 with devices: 0 means no phone has registered yet, not a failure.
  • duplicate: true: the dedupe_key had already been used. The call succeeded and nothing was pushed. This is not an error; a retrying cron job should not treat it as one.
  • muted: true: the user muted this source. Stored in history, not pushed.
  • remaining / reset_at: calls left in this key's hour, and when the window resets.

Choosing a source#

The source is the name your phone shows for the sender, and each distinct name becomes its own row in the app's Sources tab, where it can be muted, silenced, coloured or given its own tone. That only works if each name means one thing:

  • Give every script or job its own name, and keep it the same on every run: "Nightly backup", "CI", "Rewards watcher", "Safeway clipper".
  • Do not use a machine or account name ("home-server", "MacBook", "cron") for several jobs. They all land in one row, so muting the chatty one mutes the one you cannot miss.
  • Do not put changing details in it (a date, a count, a hostname per run). Every variant becomes a new row. Put those in the title or body instead.
  • Something that watches other jobs (a watchdog, a health check) should send under the name of the job it is reporting on, so its alert sits with that job's own notifications.

A name appears the first time it is used. A job that only sends when something breaks would then be missing from Sources until the night it fails, so it can register its name first, sending nothing (below). Renaming a sender starts a new row, and the old one can be removed with DELETE /v1/sources/{id} or in the app.

POST /v1/sources#

List a source in the app before it has sent anything, so it can be muted, capped or given a tone ahead of time. Same key as /v1/notify; free, and not counted against the hourly limit.

POST /v1/sources
X-Nowlark-Key: nlk_…
Content-Type: application/json

{"source": "Nightly backup"}
{"id": "src_…", "name": "Nightly backup", "created": true}

created: false means the name was already there; nothing changes, which makes it safe at the top of every run. It does bring back a name removed in the app, so the Python client remembers what it has registered (nowlark.register("Nightly backup")) and asks once per name per machine; from a shell, nowlark --register "Nightly backup". 409 too_many_sources past 500 names on one account.

Priority#

Prowl's scale, kept identical so numbers already in your scripts still mean what they meant. What the levels do is different, because iOS can do more than it could when Prowl was written.

Name On the phone
-2 Very low Silent, no sound, waits in the notification list
-1 Low Silent, no sound
0 Normal Sound and a banner
1 High Breaks through a Focus (time-sensitive)
2 Urgent Breaks through a Focus, top of the list, and repeats until opened

Priority 1 and 2 use iOS's time-sensitive interruption level. Prowl had no equivalent: its priority only changed how the banner looked, so an alert arriving during a Focus was simply not seen.

Repeat Until Opened#

A priority 2 notification alerts again until it is opened on any of the account's devices: by default every 5 minutes for up to an hour. The person chooses the interval (every 1, 2, 5 or 15 minutes, or off) and how long it keeps going (15 minutes, 1 hour or 3 hours) in the app under Settings, Alerts. Free for everyone.

  • Each repeat replaces the last one on the phone rather than stacking beside it (they share an apns-collapse-id), and reads "Still unopened" under the title. nowlark.repeat in the payload counts them, 0 on the first delivery.
  • It stops when the notification is tapped, read in the app, marked read, acknowledged, deleted, or when its source is muted or repeating is turned off. Swiping it away does not stop it: that is the point.
  • Repeats are not charged to your key's hourly limit. At most 5 notifications repeat at once per account; a newer one stops the oldest.
  • A dedupe_key that matches an earlier notification is still a duplicate and starts nothing.
  • Per source (Upgrade Pass): a source can repeat its own way, PATCH /v1/sources/{id} with {"repeat_every": 0} to turn it off for that source, or any of the intervals and lengths above. -1 goes back to the account's setting, and never needs the pass.

A notification can carry a link two ways, and both end up in the same place.

The url field is the notification's link. It appears as a button on the detail screen, and as the Open Link button on the notification itself when you also send actions: ["open"].

A URL written inside title or body becomes tappable on its own. The app detects addresses in the text the way Messages and Mail do, which matters because a sender has one url field and routinely has two things worth pointing at:

{
  "source": "CI",
  "title": "Build 4181 failed",
  "body": "Log: https://ci.example.com/b/4181  Commit: https://git.example.com/c/9f3a1c"
}

Both links are live, without spending the url field on either.

Tapping the notification itself opens its link. A notification with a url goes straight to the page when its banner is tapped, the way Prowl did; one without opens the app on that notification. The same rule applies to a row in the app's feed.

Where they open is the person's choice, not yours. In Settings › Links they pick Nowlark's own in-app browser or their default browser, and every link in the app honours it: the detail screen's button, an address inside the text, and the Open Link button on the notification, which is tapped without the app being open at all. A sender cannot override this, and there is no field to ask.

Only http and https. Prowl passed any scheme through. A link is something a phone opens on a tap, so the scheme is not a remote sender's to choose: anything else is refused 400 invalid_request. The same rule applies to a URL detected inside the text, where the app additionally allows mailto: and tel:, which the phone, not a browser, handles.

GET /v1/key/check#

Prove a key is live without spending one of its hourly calls.

GET /v1/key/check
X-Nowlark-Key: nlk_…
{"ok": true, "label": "Home server", "remaining": 1000, "reset_at": 1789665600.0}

401 if the key is unknown or revoked. Safe to call at the top of every run.

Rate limit#

1000 notifications per hour, per key, on a sliding window kept on the key itself, so it survives a server restart. Exceeding it returns 429 with {"error": "rate_limited"}.

The limit is per key, not per account, which is the reason to give each scanner its own key: one runaway loop cannot silence the others, and rotating one key does not touch the rest.

Errors#

Every error is JSON with a machine-readable error and a human message.

Status error Means
400 invalid_request Missing source, no title and no body, a non-http URL, priority out of range
401 auth_required No key sent
401 invalid_key Unknown or revoked key
401 wrong_credential You sent an account key (nla_) where an API key (nlk_) belongs
402 upgrade_required The send used a feature this account has not unlocked, e.g. image
429 rate_limited 1000/hour exceeded

Errors from the account endpoints arrive wrapped as {"detail": {"error": …, "message": …}}, which is FastAPI's shape. The bundled clients unwrap both.


Examples#

curl:

curl -sS https://nowlark.com/v1/notify \
  -H "X-Nowlark-Key: $NOWLARK_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"source":"Backup","title":"Nightly backup finished","body":"482 GB in 41 min","priority":-1}'

Python: nowlark.py, standard library only, nothing to install (curl -O https://nowlark.com/clients/nowlark.py):

from nowlark import notify

notify("Rewards watcher", "Gift card claimed", "$25 card, code ABC-123",
       url="https://example.com/rewards", priority=1, dedupe="drop-88213")

Shell, for anything that is not Python, nowlark:

nowlark "Backup" "Nightly backup finished" "482 GB in 41 min" --priority -1

Account endpoints#

Used by the iOS app. Every one takes an account key, and every one is scoped to that account's own data. An id belonging to someone else returns 404, never their row.

Identity#

Method Path Notes
POST /v1/enroll Creates an account, returns account_key once. No credentials required; capped per IP address.
GET /v1/me Account summary: device count, key count, unread count, features (what the Upgrade Pass has unlocked), history_days (the length in force) and history_choices (the lengths on offer, first one free)
PUT /v1/me/history {"days": 90}. One of history_choices: 30 for everyone, 90, 180 or 365 with the Upgrade Pass (402 upgrade_required without it, 400 for a length not on offer). Answers with the history_days now in force. Shortening deletes what is older than the new length at the next hourly purge, and it does not come back.
PUT /v1/me/sounds {"normal": "chime", "urgent": "beacon"}: the tone per priority, from sound_choices (in /v1/me, with sounds, the current choices). A level left out is unchanged; null goes back to the system sound and needs no pass. Choosing a tone is part of the Upgrade Pass (402 upgrade_required); a name not on offer is 400. Priorities -2 and -1 stay silent whatever is chosen.
PUT /v1/me/repeat {"every": 300, "for": 3600}, in seconds, either one optional. every is one of repeat_every_choices (0 is off), for one of repeat_for_choices; anything else is 400. Answers with the repeat_every and repeat_for now in force, which /v1/me also returns.
PATCH /v1/me {"display_name": "…"}
DELETE /v1/me Erases the account and everything in it. Not a flag: the rows are gone.
POST /v1/me/rotate-key {"label": "…", "revoke_others": false}. Replaces this device's key; revoke_others is the lost-iPhone case.
GET /v1/access The credentials that can open this account, with current: true on the one in use
DELETE /v1/access/{id} Signs one other credential out. Refuses to revoke the caller's own.

Moving to a phone on another Apple Account#

Normally unnecessary: the account key lives in the iCloud Keychain and in the user's CloudKit private database, so any device on the same Apple Account already has it. This is for the case where the new phone is signed into a different one.

Method Path Notes
POST /v1/transfer/start Returns a code like K4M2-P7QX, valid 10 minutes, single use
POST /v1/transfer/claim {"code": "…"} → a new account key for the same account. The issuing device keeps working.

Devices, keys, history, sources#

Method Path Notes
POST /v1/devices {apns_token, environment, name, model, platform, os_version, app_version}. platform is ios, ipados, macos, watchos or visionos; left out, it is worked out from model, and the device list returns it either way. environment is production or sandbox; see below. The first device is free; more are part of the Upgrade Pass. Re-registering a token already on the account is an update, never a new device, so a reinstall or a restore never counts against the limit.
GET/DELETE /v1/devices[/{id}]
POST /v1/keys {"label": "…"} → the key, shown once. The first is free; more are part of the Upgrade Pass (402 upgrade_required).
GET /v1/keys Never includes the key itself; only its prefix, usage and remaining calls
PATCH /v1/keys/{id} Rename
POST /v1/keys/{id}/rotate New key, same label, old one dead immediately
DELETE /v1/keys/{id} Revoke
GET /v1/notifications ?limit=&before=&unread_only=. Goes back as far as the account's chosen history length: 30 days by default, or 90, 180 or 365 with the Upgrade Pass. Older rows are deleted hourly, not hidden.
POST /v1/notifications/read {"ids": [...]} or {"ids": null} for all. Stops any repeat.
POST /v1/notifications/{id}/ack Stops Repeat Until Opened without marking it read, for anyone who keeps unread as a to-do list. Each notification in the list carries repeating: true while it is still repeating.
DELETE /v1/notifications/{id}
GET /v1/sources What has been sending, with each source's colour and silent, and last_sent_at (null for a name registered and not used yet)
PATCH /v1/sources/{id} {"priority_cap": 1}: the highest priority this source's notifications arrive at (-1, 0 or 1; 2 removes it). It only lowers, never raises, and applies from the next notification on; capping below 2 also stops one already repeating. {"muted": true}: still recorded, not pushed. Also {"colour": "violet"} (one of amber, blue, graphite, green, indigo, rose, violet, or "" to clear), {"silent": true}, {"sound": "knock"} for a tone of its own (Upgrade Pass; "" goes back to the priority's), and {"repeat_every": 60, "repeat_for": 10800} for its own Repeat Until Opened (Upgrade Pass; 0 turns it off for this source, -1 follows the account's). Each is independent: a field left out is unchanged, never cleared.
DELETE /v1/sources/{id} Forget the name. History is untouched; it reappears if used again
GET /health Includes apns_configured

The Upgrade Pass#

Used by the app, not by scripts. Everything a script can do is free, and none of these endpoints affect sending.

Method Path Notes
GET /v1/pass {windows, features, history_days}: the resolved entitlement set
POST /v1/pass/redeem {"transactions": ["<jws>", …]} from StoreKit's Transaction.all. Idempotent. Anything refused comes back named in refused rather than being dropped.
POST /v1/pass/notify App Store Server Notifications V2. Called by Apple, not by you: it carries no key, and the JWS signature is the authentication.

Every signed payload is verified against a pinned Apple Root CA - G3, not the system trust store, and checked for this bundle id, this product and an accepted App Store environment. A transaction is bound to one account by appAccountToken; presenting someone else's is refused by name.

On sources: you never create one. The source you put in a send is recorded the first time it is seen, so a new name simply appears in the app with a mute switch. The cost of that is a typo creating a name that sits there forever, which is what DELETE /v1/sources/{id} is for.

On environment: a Development-signed build's device token is only valid against Apple's sandbox APNs host, and an App Store build's only against production. Sending to the wrong one returns BadDeviceToken, which naive servers read as "phone is gone" and deactivate, after which notifications stop silently and permanently. The app reports which kind of build it is, and the server retries the other host once on a refusal and writes the correct answer back to the device row. A wrong guess costs one extra request, once, instead of every notification thereafter.


Buttons#

actions puts up to three buttons on the notification, chosen from a fixed vocabulary. It is fixed because iOS renders buttons from a category the app registered before the notification arrived. Nothing in a payload can name a button, so the set has to be knowable in advance.

verb button what it does
open Open Link opens url. Requires url, or the send is refused
copy Copy puts copy_text on the clipboard without opening the app
mute Mute Source mutes this source
done Mark as Read marks it read from the Lock Screen
{
  "source": "Rewards watcher",
  "title": "Gift card claimed",
  "body": "$25 gift card",
  "url": "https://example.com/rewards",
  "actions": ["copy", "open"],
  "copy_text": "4K2P-99MX-TTQ1"
}

copy is the one worth reaching for. A code you were sent is most useful copied straight off the Lock Screen, without unlocking anything.

Nowlark Forms#

The service every site and app posts its forms to: skail, orgomedia and nowlark. It is separate from notifications (its own tables, its own key), and each new submission is announced on the owner's phone as a Nowlark notification from <Site> inquiries, or <Site> bugs for a bug.

POST /v1/forms/{site}#

No credential, because a web page or an app binary cannot keep one. Send application/json, multipart/form-data or application/x-www-form-urlencoded (a plain HTML form works).

Field Limit
kind required: inquiry, bug, feature, question
message required 5000 characters
name, email optional; email must look like an address 120, 254
service, app, version, build, os, device, locale optional context 120, 60, 40, 40, 80, 80, 35
diagnostics optional JSON object (a string in a form post) 16 KB
install_id optional; the app's anonymous, resettable id 8 to 64 of A-Za-z0-9._-
attachments multipart only, up to 3 images (png, jpeg, gif, webp, heic) 5 MB total
log multipart only, one zip 2 MB (counts toward the 5 MB)
website honeypot: a form field people cannot see. Leave it empty.

Files are judged by their bytes, not their declared type. The whole request may not exceed the attachment cap plus 256 KB; it is refused while it is read, not after.

curl -X POST https://nowlark.com/v1/forms/skail \
  -H 'Content-Type: application/json' \
  -d '{"kind":"inquiry","name":"Alice","email":"alice@example.com","message":"Can you help?"}'
# {"id":"frm_4c1e…","received":true}

The id is the reference to show the sender; quoting it is how they ask for the message to be removed.

A browser request carries an Origin header, and that origin must be one of the site's own (skailstudio.com, orgomedia.ai, nowlark.com, with and without www), otherwise 403 origin_not_allowed. OPTIONS answers the preflight. Apps and servers send no Origin and are not subject to this; what bounds them is the limit below. A filled website field is answered with a success and stored nowhere.

Limits: 5 submissions per hour per sender per site, and 20 per day per install_id, then 429 rate_limited with Retry-After. A submission that fails validation does not use any of it. Neither the address nor the install id is stored, only a keyed hash that is forgotten after two hours and 25 hours.

Status error Means
400 invalid_request, field_too_long, too_many_attachments, unsupported_attachment Something in the form
403 origin_not_allowed A browser page that is not the site
404 unknown_site Not skail, orgomedia or nowlark
413 too_large, attachments_too_large, log_too_large Over a size cap
415 unsupported_media_type Not JSON or a form
429 rate_limited Over the hourly or daily limit
503 attachments_full The store for files is full (200 MB across all senders); resend the message without them

Reading submissions (the site's admin key)#

Authorization: Bearer nfa_…. The key opens one site: a key for skail asked for orgomedia gets 403 wrong_site, and there is no request that names another site's rows. These endpoints have no CORS headers; the key belongs to a server.

GET /v1/forms/{site}/submissions Newest first. Filters kind, status (new, read, replied, archived, spam), q (searches message, name, email, service, app, note and reference), before (a created_at from the previous page's next_before), limit (default 50, at most 200).
GET /v1/forms/{site}/submissions/{id} One submission with its diagnostics and its attachments (each has a url).
PATCH /v1/forms/{site}/submissions/{id} {"status": "replied", "note": "called Tuesday"}; either or both. The note is private and is never sent back to a sender. "note": "" clears it.
DELETE /v1/forms/{site}/submissions/{id} Erase by reference, with its files.
GET /v1/forms/{site}/attachments/{id} The file, as a download (Content-Disposition: attachment, nosniff).

Retention#

Submissions are kept 24 months (730 days) and attachments 180 days, swept hourly; the privacy policy states both and is generated from the same two settings.

Making a site's key#

Only its hash is stored, so a key is replaced, never recovered:

fly machine exec <machine> "python forms_admin.py mint skail" -a nowlark-api

The old key stops working at once.

The site's own key, and its visits#

Each site's server also holds a site key, nfs_…, which is not the admin key: it cannot read a submission. It does two things.

POST /v1/sites/{site}/hits with Authorization: Bearer nfs_… posts page visits in a batch (up to 200 hits and 256 KB, 1,200 batches an hour):

{"hits": [{"path": "/pricing?utm=x", "referrer": "https://google.com/search?q=x",
           "user_agent": "Mozilla/5.0 …", "ip": "203.0.113.7", "ts": 1790000000}]}

Answers {"accepted": n, "dropped": m}. Nothing is kept of the address or the user agent: the server makes a keyed hash of them under a key that is new every day and deleted after two, and stores that, the path (no query), the referring host, and desktop, mobile or tablet. Crawlers, scripts, an empty agent, the console's own pages and anything that is not a page path are dropped and counted in dropped. ts is believed only when it is within the last two days. Visits are kept 90 days. nowlark.com counts its own pages in the same table without a key.

The key also lets a site's server front its console: a request that carries it and X-Console-Site is served on that site's own paths (/login, /console/...), and X-Console-Client-IP is then the visitor's address for the sign-in lockout. The same headers without the key are ignored.

fly machine exec <machine> "python forms_admin.py mint-site skail" -a nowlark-api

The admin console#

One implementation, at /_console/{site}/..., for skail, orgomedia and nowlark; nowlark.com serves its own at /login and /console. skailstudio.com and orgomedia.ai forward /login, /console/* and /_console/* to it, so each console lives on its own host with its own cookie.

Sign-in is a password and then a code (a 6-digit authenticator code or a one-use recovery code), and five wrong answers lock that account and address for 15 minutes, the right answer included. A new account, from python console_admin.py create <site> <email>, has a temporary password and key: its first sign-in takes the temporary code, then makes the person choose a password and enrol their own authenticator, and shows recovery codes once. The temporary key stops working then. Accounts belong to one site each.

The screens are the same on every site: Home (visitors and pageviews for 7 or 30 days with a sparkline, top pages and referrers, form conversion, and the figures that suit that app), Inbox (All, Inquiries, Bugs, Feature requests; status new, read, replied, archived; search; every field and diagnostic; a reply that opens the owner's mail with the reference and the message quoted; a private note) and Settings (password, authenticator, recovery codes, sign out everywhere).


ntfy's publish API#

Everything above is the API worth writing against. This section and the next are for the senders you cannot rewrite.

Uptime Kuma, anything built on Apprise (changedetection.io and many more) and a long list of other self-hosted apps have an ntfy notifier that lets you set the server. Nowlark answers ntfy's publish API, so that notifier works unchanged:

In the tool Put
Server URL https://nowlark.com
Topic Any name. It becomes the source, so give each tool its own
Access token Your nlk_… key
Priority ntfy's 1 to 5, which is Nowlark's −2 to 2 step for step: 4 and 5 break through a Focus

For Apprise, the URL is ntfys://nowlark.com/<topic>?token=nlk_…. The ?token= form matters: Apprise only sends a bare nlk_…@ as a token if it starts with tk_.

curl -sS https://nowlark.com/backup \
  -H "Authorization: Bearer nlk_…" \
  -H "Title: Nightly" -H "Priority: high" \
  -d "412 GB in 38 minutes."

What is accepted: POST|PUT /<topic> with the message as the body or as message=; POST / with a JSON document naming its topic; Title, Priority (1 to 5 or min, low, default, high, max, urgent) and Click as X- headers, plain headers or query parameters; the key as a Bearer token, as the password of Basic auth, or in ?auth=. Click, or the first view action, becomes the notification's link. Tags, icons, attachments, e-mail and delays are accepted and ignored rather than refused. The same routes also answer under /ntfy.

Errors are ntfy's JSON ({"code": 40101, "http": 401, "error": "…"}), and a key that does not work is always 401, which is what a tool's "Test" button reports as needing authorization.

Nowlark is not an ntfy server to subscribe to. It only receives: the notifications go to the Nowlark app, not to an ntfy client.

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.

Many apps with Prowl built in 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 option, use that, above.

Point it at https://nowlark.com and paste a Nowlark key where it asks for a Prowl key. That is the whole change.

GET|POST /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, and now http/https only
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>

GET|POST /publicapi/verify#

Checks a key. Same XML, and unlike Prowl's 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 url is refused. Prowl passed anything through.
  • verify is 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.
  • providerkey does 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. server/prowl.py translates the fields and calls the same function /v1/notify calls. 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.

What Nowlark does that Prowl did not#

  • Time-sensitive delivery. Priority 1 and 2 break through a Focus.
  • Urgent means urgent. Priority 2 keeps alerting until someone opens it, instead of one buzz you sleep through.
  • Deduplication. dedupe_key makes a retrying cron job safe.
  • Grouping. group_key stacks related alerts instead of flooding.
  • Muting, per source, without touching the sender. Still recorded in history.
  • Silencing, per source. A silenced source still arrives and still shows a banner; it just makes no sound, whatever its priority. That is deliberately not the same as muting (no push at all) or low priority (silent and no banner): a chatty scanner can be worth seeing and never worth hearing at 3am.
  • A colour, per source, carried to the app as nowlark.source_colour so a busy feed is readable without reading it.
  • Per-key usage and rotation. Each scanner gets its own key; rotating one is one tap.
  • Links you did not have to spend the url field on. An address written in the text is tappable, so a build log and the commit both work.
  • Links open where the person chose, in the app or in their own browser, and the same way from the notification as from the app.
  • A free check. /v1/key/check does not consume an hourly call. Prowl's verify did.
  • JSON, not XML. No xml.etree in your scanners to read one number.
  • Structured outcomes. duplicate, muted, delivered and devices say what actually happened, rather than a bare 200.