Home/Guides/Send from Node.js
Guide · Node.js

How to send a push notification from Node.js

A build finishes, a queue drains, a webhook fires at 3am — and your phone buzzes. In Node it's a single fetch to your Lert URL, no dependencies required on Node 18+. Here's the tidy, production-ready version.

What you'll need

The Lert app on your iPhone and Node.js 18 or newer (that's where the global fetch landed). Open the app and copy your endpoint — it looks like https://lert.dev/p/your-id. That URL is the authentication: no API key, no token. Keep it out of committed code and regenerate it in the app if it leaks.

Everything below is plain HTTP, so it runs identically in an Express route, a serverless function, a Nest.js service, or a one-off script. Read the endpoint from process.env rather than hard-coding it — that keeps the URL out of git and lets you point a dev build at a throwaway channel while production stays quiet.

The minimal example

No install, no import. On Node 18+ this just runs. All fields — title, body, tag — are optional.

Node · fetch
await fetch("https://lert.dev/p/your-id", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ title: "Build done", body: "main · 1m 52s" }),
});

That's it. Your phone lights up within a second or two — even locked, even with a paired Apple Watch on your wrist.

An async notify() helper

Wrap it so you can call it anywhere. Read the endpoint from an environment variable, add a timeout with AbortSignal, and return whether delivery succeeded.

Node · notify()
const LERT_URL = process.env.LERT_URL; // https://lert.dev/p/your-id

export async function notify(title, body = "", tag) {
  const payload = { title, body };
  if (tag) payload.tag = tag;

  const res = await fetch(LERT_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(10000),
  });

  if (!res.ok) throw new Error(`Lert ${res.status}`);
  const data = await res.json();
  return data.delivered === 1;
}

// usage
await notify("Deploy live", "prod · v2.4.0", "ci");

The optional tag is a free-form label shown in your in-app history, so a deploy ping is easy to tell apart from a cron ping later.

Checking the response

A success is 200 with {"delivered":1}. Handle the other outcomes so a silent failure never surprises you:

  • 200 {"delivered":0} with a warning — valid endpoint, but no device registered yet. Open the app once on your phone.
  • 404 {"error":"unknown endpoint"} — the URL is wrong or stale. Grab a fresh one from the app.
  • 429 — rate limited (60/min per IP, 50/day free). Back off using Retry-After.
Node · retry on 429
export async function notify(title, body = "") {
  for (let attempt = 0; attempt <= 1; attempt++) {
    const res = await fetch(LERT_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title, body }),
    });
    if (res.status === 429 && attempt === 0) {
      const wait = Number(res.headers.get("Retry-After") ?? 5);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    return res.json();
  }
}

Plain text vs JSON

For a one-liner you can skip JSON entirely. The catch: if the text contains = or &, set Content-Type: text/plain so it isn't treated as form encoding.

Node · plain text
await fetch(LERT_URL, {
  method: "POST",
  headers: { "Content-Type": "text/plain" },
  body: "Queue drained — 0 jobs left",
});

Prefer axios?

If axios is already in your dependency tree, it's a one-liner too. Note that axios throws on non-2xx by default, so a 404 or 429 lands in your catch.

Node · axios
import axios from "axios";

await axios.post(LERT_URL, {
  title: "Report ready",
  body: "1,204 rows exported",
  tag: "reports",
});

A real-world snippet

Guard a long task with try/finally so you're pinged on success and failure alike — perfect for a nightly job or a slow migration.

Node · guard a task
try {
  await runMigration();
  await notify("Migration done ✅", "0 errors", "db");
} catch (err) {
  await notify("Migration failed ❌", String(err).slice(0, 200), "db");
  throw err;
}

Running this on a schedule or in CI? The same helper drops straight into a GitHub Action, a cron job, or a deploy pipeline. Working in another language? See Python or curl.

Why it's this simple

There's no Lert npm package to install because there's nothing to configure. The endpoint URL is the credential — no auth handshake, no token refresh, no SDK to keep up to date. That's the whole point: notifying your phone from Node is a few lines you'll actually remember.

And because it's just fetch, it drops into whatever you already run: call it from a process.on("exit") handler, fire it from a queue worker's completion callback, or wrap it around an error boundary so an unhandled rejection buzzes your phone before the process dies. Small primitive, lots of places to use it.

FAQ

Do I need node-fetch or axios?

No. Node.js 18+ ships a global fetch, so you can POST to Lert with zero dependencies. axios is optional and only worth it if you already use it elsewhere.

How do I send plain text instead of JSON?

Pass a string as the body and set Content-Type: text/plain. Do this when your message contains = or & so it isn't interpreted as form data.

How do I handle a 429 rate limit?

Check for res.status === 429, read the Retry-After header, wait that many seconds, and retry. Limits are 60 requests/minute per IP and 50/day on the free plan.

Can I use this with top-level await?

Yes. In an ES module or a modern Node script you can await notify() at the top level, or call it inside any async function.

Let Node tap you on the shoulder.

Download Lert, copy your URL, add an async notify(). Your phone knows the second the job is done.

Get the app iOS