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).
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.iwcStep 1: Set up push
iwc push setup --web --subject mailto:coach@club.example✓ 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:
{
"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.vapidPublicKeyreads the public key from the environment.pwa.icon(a 512px PNG) becomes the app icons.- IWC now serves
/manifest.webmanifestand 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
{
"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:
{
"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" } }
]
}{
"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"] }
}{ "name": "SendAnnouncement", "action": "deliver-announcement.action.iwc", "retries": 3, "timeout": "60s" }The job's action is one push step:
{
"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.pathopens that screen when the notification is tapped.- IWC deletes subscriptions the provider reports as gone.
rholds{devices, sent, failed, removed}. - Jobs are stored in the database, survive restarts and retry with backoff (
retries: 3).
Step 4: The screen
{
"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
{
"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)".
iwc test && iwc security ✓ 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:
iwc push testSending "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
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 siteWeb Push needs HTTPS (localhost is exempt). See push notifications for Android and iOS.
Troubleshooting
| Problem | Fix |
|---|---|
| The notifications button does nothing | The browser blocked the permission, or the page isn't served over HTTPS. |
| The Allow card never appears | You 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 warning | Add a square PNG of 512px or more and set pwa.icon. |
went through a simulated provider | Development without keys: run iwc push setup (or set the VAPID keys). |
| Notifications stop for one browser | Its subscription expired; IWC deletes it when the provider reports it gone. |
Source: apps/marketing/content/docs/tutorials/push-notifications.md