Notify your phone from any script
August 31, 2026
Your backend can already chat and render dashboards on your phone after the Express guide. This guide adds the missing sense: interruption. A cron job finishes, a training run converges, a scraper hits an error, and your lock screen tells you. By the end, any script on your machine sends a push with one HTTP call.
Two things to know up front. Push delivery needs Parlane Pro: pairing the relay is free and happens inside the connect scan, but delivery through the hosted relay is the paid part (see pricing). Notification titles and previews are visible to the relay and Apple. The additional encrypted payload is opaque, but it does not hide the preview. Use generic text for sensitive events.
What you’ll build
- A push declaration in your manifest: one line.
- A one-scan connect QR that pairs your server with the relay at the moment your phone connects.
- A
notify()function on your server that seals the payload, signs the request, and calls the relay’sPOST /notify. - A small
/notify-meroute of your own, so a curl one-liner or a Python script can trigger the whole chain.
Where ntfy and Pushover fit
If a push notification is the only thing you need, ntfy and Pushover are excellent single-purpose tools, and nothing in this guide argues otherwise. The trade here is different: the backend that sends the push is also a full native app on your phone, so the notification deep-links into a chat thread or dashboard served by that same backend, and “what happened?” is one tap away instead of a second tool. The extra payload can be encrypted, and the relay is open source (AGPL) so you can audit exactly what it sees: parlane-ai/relay. The tunneling, queueing, and encryption parts of that relay are self-hostable; push delivery itself always routes through the hosted relay, because only Parlane holds the Apple key that can reach the app. The relay overview covers that split honestly.
Prerequisites
- The server from the Express guide, or any backend that serves a Parlane manifest.
- Parlane on your iPhone, installed from TestFlight or the App Store. Those builds get production APNs tokens; a local development build gets a sandbox token the hosted relay cannot deliver to, so a push sent to it never arrives.
- Parlane Pro on that phone, with notifications allowed. Pairing works without Pro; delivery is the Pro part, and the relay says so in its response instead of failing silently.
- Node.js 18 or later, plus Python 3 for the script example.
How a send works
Your server calls POST /notify on the hosted relay with three fields:
serverId (public routing metadata from pairing), encryptedPayload (the
real notification, sealed with a key only your server and your phone can
derive), and apnsHint (the visible banner: title, body, and an
optional deepLink). The relay validates the hint against
spec/push.schema.json and forwards the sealed payload untouched; its
logs are ids, roles, and byte counts, never content. The hint is what the
lock screen renders, so when the content is sensitive, keep the hint
generic. The current app displays the hint and does not decrypt the sealed
payload into a private preview. Fetch sensitive details directly from your
server after the user opens the app.
1. Declare push in the manifest
Add one line to src/manifest.js from the previous guide:
// src/manifest.js
surfaces: {
chat: { endpoint: 'rest', attachments: false },
dashboards: [
{ id: 'home', title: 'Home', source: '/ui/home', refresh: { mode: 'manual' } },
],
push: { relay: true },
},
That boolean is the whole switch. Without it there is no push; with it the
app wires itself to the hosted relay. You don’t set a relay URL, and you
never touch Apple credentials: no .p8 key, no Team ID, no APNs topic.
Only the hosted relay holds the key that can reach the app.
2. Pair your server with the relay
Install the one new dependency:
npm install libsodium-wrappers
libsodium-wrappers is the audited reference implementation of exactly
the primitives this contract uses; the relay and the app run the same
library. Create src/relay.js:
// src/relay.js
import { createHash, createHmac } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import _sodium from 'libsodium-wrappers';
const RELAY = 'https://relay.parlane.ai';
const FILE = 'relay-credentials.json';
let sodium;
export async function initCrypto() {
await _sodium.ready;
sodium = _sodium;
}
const b64u = (bytes) => sodium.to_base64(bytes, sodium.base64_variants.URLSAFE_NO_PADDING);
const fromB64u = (str) => sodium.from_base64(str, sodium.base64_variants.URLSAFE_NO_PADDING);
export const loadCreds = () => JSON.parse(readFileSync(FILE, 'utf8'));
const saveCreds = (creds) => writeFileSync(FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
// v2 relay auth: the signature covers the exact raw body, so build the body
// first, then sign those bytes. A GET signs the hash of the empty string.
function bearer(creds, rawBody) {
const ts = Date.now();
const bodyHash = createHash('sha256').update(rawBody).digest('hex');
const sig = createHmac('sha256', creds.serverAuthKey)
.update(`${creds.serverId}:server:${ts}:${bodyHash}`)
.digest('base64url');
return `Bearer v2.${ts}.${sig}`;
}
async function relayPost(path, payload, creds) {
const body = JSON.stringify(payload);
const headers = { 'content-type': 'application/json' };
if (creds) headers.authorization = bearer(creds, body);
const res = await fetch(`${RELAY}${path}`, { method: 'POST', headers, body });
if (!res.ok) throw new Error(`${path} -> ${res.status} ${await res.text()}`);
return res.json();
}
// One-time self-bootstrap (sanctioned by the integration kit): claim a pairing
// against a throwaway key to obtain a serverId + serverAuthKey, then discard
// the throwaway. Real phones pair later by redeeming one-scan tokens.
export async function bootstrap() {
const me = sodium.crypto_kx_keypair();
const throwaway = sodium.crypto_kx_keypair();
const { code } = await relayPost('/pair/start', { appPubKey: b64u(throwaway.publicKey) });
const claim = await relayPost('/pair', { code, serverPubKey: b64u(me.publicKey) });
saveCreds({
serverId: claim.serverId,
serverAuthKey: claim.serverAuthKey,
serverPubKey: b64u(me.publicKey),
serverPrivKey: b64u(me.privateKey),
appPubKey: null, // filled in when your phone redeems a pair token
});
return loadCreds();
}The bootstrap runs once and writes relay-credentials.json. Treat that
file like a password: serverAuthKey authenticates every later call, and
serverPrivKey is half of the encryption channel. The relay never sees
the private key. It stores public keys, relay authentication credentials, and
routing data.
3. Put a pair token in the connect QR
The recommended pairing path is one scan: your server pre-mints a
single-use token, embeds it in the connect QR, and the app redeems it the
moment the user scans, so connecting and pairing are the same gesture.
Append to src/relay.js:
// src/relay.js (continued)
// Mint a one-time pair token for the connect QR. Single use, five-minute TTL.
export async function preparePairToken(creds) {
const prepared = await relayPost(
'/pair/prepare',
{ serverId: creds.serverId, serverPubKey: creds.serverPubKey },
creds,
);
return prepared.pairToken;
}
// Poll until the phone redeems the token, then persist its public key.
// Without appPubKey the server cannot derive the session key, and every
// sealed payload it sent would be undecryptable.
export async function waitForRedeem(creds, pairToken) {
const url = `${RELAY}/pair/prepare/status?pairToken=${encodeURIComponent(pairToken)}`;
for (;;) {
const res = await fetch(url, {
headers: { authorization: bearer(creds, ''), 'x-server-id': creds.serverId },
});
if (res.status === 410) throw new Error('pair token expired before the scan; restart to mint a fresh one');
// A transient 5xx deserves a retry with backoff; this minimal loop treats it as fatal.
if (!res.ok) throw new Error(`status poll -> ${res.status}`);
const status = await res.json();
if (status.state === 'REDEEMED') {
saveCreds({ ...creds, appPubKey: status.appPubKey });
return;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}The poll is required, not a nicety. Your server minted the token before
the app ever ran, so it has never seen the phone’s public key; until the
poll returns REDEEMED with appPubKey, it cannot derive the session
key that seals payloads.
Extend the QR printer from the previous guide with two params:
// src/qr.js: printConnectInfo now takes the pairToken and appends two params.
export function printConnectInfo(port, pairToken) {
// ...unchanged until connectUrl...
const relayPart =
`&relay=${encodeURIComponent('https://relay.parlane.ai')}` +
`&pair=${encodeURIComponent(pairToken)}`;
const connectUrl =
`parlane://connect?url=${encodeURIComponent(primary)}${tokenPart}${relayPart}`;
// ...rest unchanged
}Then wire it together in index.js:
// index.js
import { createApp } from './server.js';
import { printConnectInfo } from './src/qr.js';
import { initCrypto, loadCreds, bootstrap, preparePairToken, waitForRedeem } from './src/relay.js';
await initCrypto();
let creds;
try {
creds = loadCreds();
} catch {
creds = await bootstrap();
}
const pairToken = await preparePairToken(creds);
const port = Number(process.env.PORT) || 8787;
createApp().listen(port, '0.0.0.0', () => printConnectInfo(port, pairToken));
waitForRedeem(creds, pairToken).then(
() => console.log('Phone paired. Push is live.'),
(err) => console.error(`Pairing incomplete: ${err.message}`),
);
Start the server and scan the QR with your phone. If you already connected during the previous guide, scan again: the app runs the connect flow and completes pairing this time. The pair token lives five minutes; if it expires before you scan, restart the server for a fresh one. After the redeem, the app registers its device push token with the relay on its own. You do nothing for that step.
4. Seal and send
Now the send itself: derive the session key, seal the payload, sign the
request, post it. Append to src/relay.js:
// src/relay.js (continued)
// Same primitives as the relay's reference implementation
// (src/crypto/e2e.ts): X25519 crypto_kx agreement, XChaCha20-Poly1305 seal.
export async function notify(title, body = '', deepLink) {
const creds = loadCreds();
if (!creds.appPubKey) throw new Error('no phone paired yet: scan the connect QR first');
const session = sodium.crypto_kx_server_session_keys(
fromB64u(creds.serverPubKey),
fromB64u(creds.serverPrivKey),
fromB64u(creds.appPubKey),
);
const content = { title, body, ...(deepLink ? { deepLink } : {}) };
const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
JSON.stringify(content), null, null, nonce, session.sharedTx,
);
const sealed = { n: b64u(nonce), c: b64u(ciphertext) };
return relayPost('/notify', {
serverId: creds.serverId,
encryptedPayload: JSON.stringify(sealed),
apnsHint: content,
}, creds);
}
Here the sealed payload and the hint carry the same content. The relay and
Apple can read the hint; sealing a second copy does not make the visible
preview private. For sensitive sends, use a generic hint such as
{ title: 'Update' }. The current iOS app does not decrypt the sealed payload
into a notification preview. Let the user open the connected app to fetch
sensitive details directly from your server.
Read the response instead of trusting the status code. Every 200 has
the same shape: delivered, deviceCount, and transport are always
present, and note appears only when deviceCount is 0:
{ "delivered": 1, "transport": "apns", "deviceCount": 1 }
deviceCount: 0 means the relay holds no registered device for this
pairing and sent nothing. The usual causes: the user isn’t on
Parlane Pro (pairing is free; delivery is Pro) or turned
notifications off. The note says which, in user-ready words, so surface
it rather than reporting a generic failure. delivered below
deviceCount means the provider rejected stale tokens, which the relay
prunes on the spot.
5. Notify from any script
The sealing key never leaves your server, which is why a raw curl to the
relay can’t work and why that’s a feature. So give your server one route
that wraps notify(). This route is yours, not part of the
Parlane contract:
// server.js: one route of your own, behind the same bearerAuth
import { notify } from './src/relay.js';
app.post('/notify-me', async (req, res) => {
const { title, body, deepLink } = req.body || {};
if (!title) {
return res.status(400).json({ error: { code: 'bad_request', message: 'title is required' } });
}
try {
res.json(await notify(title, body, deepLink));
} catch (err) {
res.status(502).json({ error: { code: 'notify_failed', message: err.message } });
}
});
Now any script reaches your lock screen. From a shell, a cron job, or a CI step:
curl -s -X POST http://localhost:8787/notify-me \
-H 'content-type: application/json' \
-H "authorization: Bearer $AUTH_TOKEN" \
-d '{"title":"Backup finished","body":"nightly rsync: 0 errors"}'
From Python, standard library only:
# notify.py
import json, os, urllib.request
def notify(title, body="", deep_link=None):
payload = {"title": title, "body": body}
if deep_link:
payload["deepLink"] = deep_link
req = urllib.request.Request(
os.environ.get("NOTIFY_URL", "http://localhost:8787/notify-me"),
data=json.dumps(payload).encode(),
headers={
"content-type": "application/json",
"authorization": "Bearer " + os.environ["AUTH_TOKEN"],
},
)
with urllib.request.urlopen(req) as res:
return json.load(res)
if __name__ == "__main__":
result = notify(
"Training run finished",
"loss 0.041 after 12 epochs",
deep_link={"target": "dashboard", "id": "home"},
)
print(result) # {'delivered': 1, 'transport': 'apns', 'deviceCount': 1}
Prefer to skip the Node helper and seal from Python directly? The relay
contract is language neutral: X25519 crypto_kx key agreement,
XChaCha20-Poly1305 sealing, and HMAC-SHA256 v2 signatures. PyNaCl exposes
all three. The byte-exact reference is src/crypto/e2e.ts and
src/ws/auth.ts in the relay repository,
and the README there documents every endpoint and status code.
6. Tap it
The deepLink decides where a tap lands: { "target": "dashboard", "id": "home" } opens the dashboard from the previous guide,
{ "target": "thread", "id": "<conversation_id>" } opens a chat thread,
and omitting it opens the app’s default surface. That’s the payoff over a
plain notifier: the Python example above drops you onto the live
dashboard next to the number that prompted the push, and the chat about
it is one tab away.
When it doesn’t arrive
Work through the response before blaming the network:
| Symptom | Cause | Fix |
|---|---|---|
deviceCount: 0 with a note |
The user isn’t on Pro, or notifications are off | The note says which; show it to the user |
delivered below deviceCount |
Stale device tokens | None needed; the relay prunes them automatically |
200 with delivered: 1, no banner |
The phone runs a local development build, so its token is sandbox | Test against a TestFlight or App Store build |
401 unauthorized |
Signature not over the exact bytes sent, or timestamp outside the 60-second skew | Build the body first, sign those bytes; check the clock |
404 unknown_server |
This relay holds no pairing for that serverId |
Bootstrap again, mint a token, rescan |
410 pair_token_expired on the poll |
More than five minutes between mint and scan | Restart the server, scan the fresh QR |
What you built
surfaces.push.relay: true: the one-line manifest switch.- A one-scan connect QR that pairs the relay with no code to read across.
notify(): a signed push request with an encrypted payload and visible preview, about 40 lines including the auth signature./notify-me: your own doorbell, reachable from curl, Python, cron, or anything else that speaks HTTP.
Honest limits: delivery requires the hosted relay and a Pro subscription,
because only Parlane holds the Apple key for the app; there
is no self-hosted push path today. The apnsHint transits Apple’s
servers as visible notification fields, so keep it generic when it matters. And the credentials file on your server is the whole
identity of this pairing; leaking it lets someone else ring your phone.
To wire the same push path into the backend you already have, use the prompt below with your coding agent.
The prompt
Working on your own backend instead of the example? Paste this into your coding agent. It carries the whole contract, so the agent can wire your endpoints and check its work against the hosted schemas.
Get Parlane on your phone