Skip to content
ONIWC
iwc 0.1.0

Tutorial 13: Implement push notifications

Objective: members turn on notifications in the browser; an admin posts an announcement; a background job delivers it to every registered device and forgets subscriptions that no longer exist.

Prerequisites: Tutorials 7 and 12. The project uses Web Push, which works in installed web apps and most desktop browsers. Native Android and iOS apps use the same setup with Firebase Cloud Messaging (iwc push setup --android --ios).

Project files
push/
├── actions/
│   ├── announce.action.iwc
│   └── deliver-announcement.action.iwc
├── api/
│   ├── announce.api.iwc
│   └── login.api.iwc
├── assets/
│   └── icon.png
├── database/
│   ├── announcement.schema.iwc
│   └── user.schema.iwc
├── integrations/
│   └── push.integration.iwc
├── jobs/
│   └── send-announcement.job.iwc
├── styles/
│   └── main.css
├── tests/
│   └── push.test.iwc
├── ui/
│   ├── home.ui.iwc
│   └── login.ui.iwc
├── app.iwc
└── package.iwc

Step 1: Set up push

Terminal
iwc push setup --web --subject mailto:coach@club.example
Output
✓ push is on in app.iwc (devices: /_iwc/push/devices)

Web push
✓ Web Push key pair created for development (vault: VAPID_PUBLIC_KEY BGmdN6_PdV_u…, VAPID_PRIVATE_KEY)
✓ integrations/webpush.integration.iwc sends to browsers (contact mailto:coach@club.example)

The setup makes the app installable, creates a Web Push key pair in the encrypted development vault, and writes app.iwc:

app.iwc
{
  "name": "Club Alerts",
  "iwc": 1,
  "theme": { "tokens": { "color-primary": "#5b4cf5", "color-on-primary": "#ffffff", "color-border": "#e6e6ef", "color-surface": "#f6f4ff", "radius": "12px", "font-body": "system-ui, sans-serif" } },
  "styles": ["styles/main.css"],
  "head": { "favicon": "/assets/icon.png" },
  "auth": { "user": "user.schema.iwc", "defaultRole": "member", "loginPage": "/login" },
  "pwa": {
    "icon": "/assets/icon.png",
    "shortName": "Alerts",
    "vapidPublicKey": { "env": "VAPID_PUBLIC_KEY" }
  },
  "push": {}
}
  • "push": {} turns on built-in push: IWC keeps the devices, so the app needs no device table and no subscribe API.
  • pwa.vapidPublicKey reads the public key from the environment. pwa.icon (a 512px PNG) becomes the app icons.
  • IWC now serves /manifest.webmanifest and a service worker (/sw.js).

iwc push shows what is set up at any time, with the command that fixes anything missing.

Step 2: The sender

integrations/push.integration.iwc
{
  "package": "@iwc/push",
  "provider": "webpush",
  "optional": true,
  "config": { "publicKey": { "env": "VAPID_PUBLIC_KEY" }, "subject": "mailto:coach@club.example" },
  "credentials": { "privateKey": { "env": "VAPID_PRIVATE_KEY" } }
}

This is the file the setup writes (here named push.integration.iwc). With optional: true, development uses the simulated provider until the keys are set, so the whole flow runs locally.

Visitors are asked automatically. A few seconds after a page loads, a card offers Allow / Not now. Allow opens the browser's permission dialog (browsers only show it after a tap) and registers the browser at /_iwc/push/devices, tied to the signed-in member. Not now hides the card for a week. Change its text, delay, pages or position under push.prompt in app.iwc (asking visitors). This screen also has a button with "action": "notifications", which asks the same way. Native Android and iOS apps register by themselves after sign-in.

Step 3: Send in the background

The announcement API is admin-only. Its action saves the announcement and enqueues a job in the same transaction:

actions/announce.action.iwc
{
  "name": "Announce",
  "description": "Saves an announcement and queues the push delivery; the job only exists if this commits.",
  "auth": "required",
  "roles": ["admin"],
  "transaction": true,
  "input": {
    "title": { "type": "string", "required": true, "maxLength": 80 },
    "body": { "type": "string", "maxLength": 200 }
  },
  "steps": [
    { "operation": "create", "resource": "announcement.schema.iwc", "as": "a", "values": { "title": "=input.title", "body": "=input.body" } },
    { "operation": "enqueue", "job": "send-announcement.job.iwc", "payload": { "id": "=a.id" } },
    { "operation": "return", "value": { "id": "=a.id" } }
  ]
}
api/announce.api.iwc
{
  "name": "announce",
  "method": "POST",
  "path": "/announcements",
  "action": "announce.action.iwc",
  "request": {
    "payload": {
      "title": { "type": "string", "required": true, "maxLength": 80 },
      "body": { "type": "string", "maxLength": 200 }
    }
  },
  "response": { "status": 201, "fields": { "id": "string" } },
  "security": { "auth": true, "roles": ["admin"] }
}
jobs/send-announcement.job.iwc
{ "name": "SendAnnouncement", "action": "deliver-announcement.action.iwc", "retries": 3, "timeout": "60s" }

The job's action is one push step:

actions/deliver-announcement.action.iwc
{
  "name": "DeliverAnnouncement",
  "description": "Sends one announcement to every registered device; IWC removes the devices the provider says are gone.",
  "input": { "id": { "type": "string", "required": true } },
  "steps": [
    { "operation": "find", "resource": "announcement.schema.iwc", "where": { "id": "=input.id" }, "as": "a", "required": true },
    { "operation": "push", "to": "all", "title": "=a.title", "body": "=a.body", "path": "/", "as": "r" },
    { "operation": "update", "resource": "announcement.schema.iwc", "where": { "id": "=a.id" }, "values": { "sent": "=r.sent" } },
    { "operation": "return", "value": { "sent": "=r.sent" } }
  ]
}
  • "to": "all" reaches every registered device. "to": "=order.customer" reaches one person's devices, and a list of ids reaches several people.
  • path opens that screen when the notification is tapped.
  • IWC deletes subscriptions the provider reports as gone. r holds {devices, sent, failed, removed}.
  • Jobs are stored in the database, survive restarts and retry with backoff (retries: 3).

Step 4: The screen

ui/home.ui.iwc
{
  "name": "Home",
  "route": "/",
  "auth": "required",
  "title": "Club alerts",
  "sections": [
    {
      "type": "section",
      "className": "page",
      "layout": { "direction": "column", "gap": "20px" },
      "elements": [
        { "type": "text", "variant": "h1", "content": "Club alerts" },
        {
          "type": "row",
          "layout": { "gap": "12px", "wrap": true },
          "elements": [
            { "type": "button", "label": "Turn on notifications", "action": "notifications" },
            { "type": "button", "label": "Install the app", "variant": "secondary", "action": "install" }
          ]
        },
        {
          "type": "column",
          "className": "panel",
          "visible": { "roles": ["admin"] },
          "elements": [
            { "type": "text", "variant": "h3", "content": "Send an announcement" },
            {
              "type": "form",
              "inputs": [
                { "name": "title", "type": "text", "label": "Title", "required": true },
                { "name": "body", "type": "textarea", "label": "Message" }
              ],
              "submit": { "api": "announce.api.iwc", "label": "Send to everyone", "success": { "message": "Queued — members will be notified.", "reset": true, "reload": true } }
            }
          ]
        },
        {
          "type": "stack",
          "layout": { "direction": "column", "gap": "10px" },
          "data": { "source": "DATABASE.announcements", "columns": ["title", "body", "sent", "createdAt"], "sort": { "field": "createdAt", "order": "desc" }, "live": true },
          "item": {
            "type": "column",
            "className": "panel",
            "elements": [
              { "type": "text", "variant": "label", "bind": "title" },
              { "type": "text", "bind": "body" },
              { "type": "text", "variant": "caption", "content": "=concat(format(createdAt, 'date-short'), ' · delivered to ', sent, ' device(s)')" }
            ]
          }
        }
      ]
    }
  ]
}

The Install the app button stays hidden until the browser says the app can be installed. You rarely need it: every PWA also shows a built-in install card by itself (an Add to Home Screen guide on iPhone), which you can reword, retime or turn off with pwa.install — see Installable apps.

Step 5: Test the whole chain

tests/push.test.iwc
{
  "name": "push",
  "cases": [
    { "name": "the app is installable", "request": { "path": "/manifest.webmanifest" }, "expect": { "status": 200, "contains": ["Club Alerts", "standalone"] } },
    { "name": "members can register a browser subscription", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "POST", "path": "/_iwc/push/devices", "body": { "token": "{\"endpoint\":\"https://fcm.googleapis.com/fcm/send/abc\",\"keys\":{\"p256dh\":\"BNcR\",\"auth\":\"tBH\"}}", "platform": "web" } }, "expect": { "status": 200, "body": { "registered": true } } },
    { "name": "registering twice is harmless", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "POST", "path": "/_iwc/push/devices", "body": { "token": "{\"endpoint\":\"https://fcm.googleapis.com/fcm/send/abc\",\"keys\":{\"p256dh\":\"BNcR\",\"auth\":\"tBH\"}}", "platform": "web" } }, "expect": { "status": 200 } },
    { "name": "members cannot send announcements", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "POST", "path": "/api/announcements", "body": { "title": "Free pizza" } }, "expect": { "status": 403 } },
    { "name": "admins can", "as": { "email": "admin@example.com", "password": "admin-pass-123" }, "request": { "method": "POST", "path": "/api/announcements", "body": { "title": "Match moved to Sunday", "body": "Same time, same pitch." } }, "expect": { "status": 201 } },
    { "name": "everyone sees the announcement and the admin sees the form", "as": { "email": "admin@example.com", "password": "admin-pass-123" }, "request": { "path": "/" }, "expect": { "status": 200, "contains": ["Match moved to Sunday", "delivered to 1 device(s)", "Send to everyone", "Turn on notifications"] } }
  ]
}

The test runner runs due jobs after each request, so the last case proves the announcement was delivered: "delivered to 1 device(s)".

Terminal
iwc test && iwc security
Output
  ✓ push › the app is installable
  ✓ push › members can register a browser subscription
  ✓ push › registering twice is harmless
  ✓ push › members cannot send announcements
  ✓ push › admins can
  ✓ push › everyone sees the announcement and the admin sees the form

6 passed, 0 failed
LOW app.iwc  admin-without-2fa
    administrators can sign in with a password alone
    fix: add auth.mfa with "requiredFor": ["admin"]

The audit's remaining finding is a recommendation for production: require two-factor sign-in for admins (Two-factor sign-in).

Step 6: Send a real notification

With iwc dev running, open the app and press Allow on the card (or Turn on notifications), then allow in the browser's dialog. Then:

Terminal
iwc push test
Output
Sending "Test notification from Club Alerts" to every registered device via http://127.0.0.1:3000 (iwc dev)…
✓ sent to 1 of 1 device(s)

--to max@example.com sends to one member, and iwc push devices lists who is registered.

Step 7: Production

Terminal
iwc push setup --target prod     # production keys, written into the server's settings.env
iwc deploy prod
iwc push test --target prod      # through the live site

Web Push needs HTTPS (localhost is exempt). See push notifications for Android and iOS.

Troubleshooting

ProblemFix
The notifications button does nothingThe browser blocked the permission, or the page isn't served over HTTPS.
The Allow card never appearsYou already allowed or blocked notifications for this site. Reset the permission (icon left of the address bar) and reload, or use a private window.
No icon warningAdd a square PNG of 512px or more and set pwa.icon.
went through a simulated providerDevelopment without keys: run iwc push setup (or set the VAPID keys).
Notifications stop for one browserIts subscription expired; IWC deletes it when the provider reports it gone.

Next: Build a real-time application.

Source: apps/marketing/content/docs/tutorials/push-notifications.md