Push notifications end-to-end

This guide walks the whole push loop: you ship a notify.json in your deploy bundle that says which writes deserve a nudge, the platform watches your app's data for a matching write and rings a doorbell on every subscribed device, and your app enrolls users and reacts to taps. It's a cross-layer story — a bundle file, a data write, and the runtime SDK all play a part — so the reference documents each piece on its own page and this guide stitches them together.

For the runtime API itself (what subscribe/state/test/watch return and throw), see backlit.push in the reference. For every field a notify.json rule can carry, see the notify.json schema. This guide links to both rather than repeating them, so it can't drift out of sync with them.

Plain text All guides SDK reference

The mental model

Push has a server half and a client half, and they meet at a write.

The doorbell that arrives is deliberately content-light — it carries a count, not your data — so the notification never leaks the payload of the write that triggered it. Your app's service worker renders it with your glow's name and icon; the tap opens the path you named in the rule.

A glow only pushes when its live deploy included a valid notify.json. Deploy without one (or ship a bundle that omits it) and push is simply off — the push.subscribe call throws rather than enrolling anyone.

Enable push in three moves

  1. Author a notify.json and put it at the root of your bundle, alongside index.html. It rides the bundle as an optional fourth file next to the three required ones — see Deploy bundle contract and Packaging the deploy bundle for how the bundle is assembled and uploaded.
  2. Enroll users from your app with push.subscribe(), gated on push.supported().
  3. React to taps with push.watch().

The rest of this guide is a worked example of each move.

Move 1 — author notify.json

A rule matches a write on the store it lands in and the verb that lands it, then optionally narrows by a condition on the write's JSON payload, picks who to notify, and supplies the static notification text. Each of those is a field in the notify.json schema — read it there for the exact shape and constraints of on, when, target, message, coalesce, and schedule; this guide only shows how they fit together.

Here's a rule for a shared RSVP list: an admin should get a nudge whenever a guest's write flips their status to going.

{
  "version": 1,
  "rules": [
    {
      "id": "rsvp-going",
      "on": { "store": "data", "op": "set", "key": "rsvp-" },
      "when": [{ "field": "status", "op": "eq", "value": "going" }],
      "target": { "roles": ["admin"] },
      "message": {
        "title": "New RSVP",
        "body": "{count} guest just RSVP'd going.",
        "url": "/admin/rsvps"
      },
      "coalesce": { "window": "5m" }
    }
  ]
}

Reading it as narrative: on picks the store and write verb this rule watches and (via key) narrows to a key prefix; when further gates on the write's payload (only fire when status equals going); target says who receives it (users whose glow permission is admin); message is the static text (with {count} filled in at send time, and url the path a tap opens); and coalesce batches a burst of matches into one doorbell over a window. The schema is the authority on every one of those fields — including which store/op combinations are legal, which stores allow an owner target, and how the window is clamped — so consult it rather than trusting this paragraph.

Ship this file at your bundle root. If it's malformed, too large, or reaches for something the platform disallows, the deploy is rejected with an error — the deploy API reports which — so you find out at deploy time, not at send time.

Move 2 — the write that trips it

Nothing about sending lives in your app. Push fires off an ordinary data write — the same write your app already makes. Given the rule above, this write is what rings the doorbell:

// A guest marks themselves going. This is a normal shared-data write;
// the matching notify.json rule turns it into an admin notification.
await backlit.data.updateJSON(`rsvp-${user.uid}`, (cur) => ({
  ...cur,
  name: user.name,
  status: "going",
}));

The write lands in the data store under a rsvp- key with status: "going" — exactly the shape the rule's on and when describe — so the platform matches it and delivers the doorbell to every admin who subscribed. Your app called no push API to make that happen; it just wrote its data. (For the JSON-write helpers this uses, see the data.getJSON / putJSON / putJSONIfMatch family in the reference and the read-modify-write pattern in Migrations.)

Move 3 — enroll users and react to taps

Sending is automatic, but a user only receives a doorbell after their browser is enrolled, and enrollment needs a signed-in user and a browser that can do push. Always probe first, then offer the opt-in only where it can work:

const cap = backlit.push.supported();     // synchronous; never throws
if (cap.supported) {
  enableBtn.onclick = async () => {
    try {
      await backlit.push.subscribe();      // prompts for OS permission
      await backlit.push.test();           // ring your own doorbell to confirm
    } catch (e) {
      if (e.code === "permission_denied") showTip("Notifications are blocked.");
      else throw e;
    }
  };
} else if (cap.requiresInstall) {
  showTip("Install this app to your home screen to turn on notifications.");
} else {
  enableBtn.hidden = true;                 // this browser can't do push
}

// While a tab is open, follow a tap to where the rule pointed it.
backlit.push.watch((e) => {
  if (e.type === "notification-click") location.assign(e.url);
});

The exact return shapes, the requirements each call imposes (a signed-in user, push enabled for the glow), and the error codes it throws (permission_denied, unsupported, requires_install, unauthenticated, …) are all in the reference — push.subscribe, push.test, push.watch, and the capability tags from push.supported. Read them there; this guide won't restate them.

Who can receive, and where

A rule's target speaks in the same permission roles the rest of the platform uses (admin / user / viewer), and enrollment needs a signed-in user — so who can actually receive a doorbell depends on the glow's auth mode. A Private glow's users are all signed in; a Public or Accounts glow serves anonymous visitors who receive nothing until they sign in. See Public, Private, and Accounts glows for which surfaces each mode's visitors can reach, and the notify.json schema's target for how roles (and the records-only owner) resolve to recipients.

No natural write? Dedicate a key

Every rule fires off a write — so when the moment you want to announce isn't something the app already stores (a "meeting is starting" button, a manual "ping the team"), invert the trigger: dedicate a key to the announcement and write to it. The write is the send button; the rule matches it like any other.

{
  "id": "announce",
  "on": { "store": "data", "op": "set", "key": "announce" },
  "target": { "roles": ["admin", "user", "viewer"] },
  "message": {
    "title": "Team update",
    "body": "There's a new announcement.",
    "url": "/"
  }
}
// The button's click handler IS the send.
sendBtn.onclick = () =>
  backlit.data.updateJSON("announce", () => ({
    text: input.value,
    at: Date.now(), // varies every send — load-bearing, see below
  }));

That at field is not decoration. Writing a value identical to what the key already holds is a no-op — the write succeeds but nothing hears it, so no doorbell rings (the exact rule is under backlit.data). Two admins sending the same "Meeting now" text minutes apart would silently drop the second send. So a dedicated notification key must always carry something that varies — a timestamp, a counter — or use per-event keys under a prefix (announce-…, matched by the rule's prefix key), which also pairs naturally with the schema's coalesce when sends can burst.

Who may pull the trigger is just the store choice again: a shared data key limits sending to signed-in non-viewers, records opens it to any signed-in user, and capture to anyone — the permissions guide is the recipe for that decision. And check you need a dedicated key at all: if the moment already writes — an anonymous contact form's capture, a member's records post — hook the rule to that write instead, exactly as Move 1's RSVP rule did.

Deploy it with an agent

You don't have to package and upload by hand. An AI assistant connected to the hosted MCP server can create the glow, deploy the bundle — notify.json included — as a draft, and promote it live once you've previewed it. See Deploy with an agent for the connection details and the two out-of-band deploy transports.