Repository navigation
PWA and Web Push
A PWA landing is a local landing that Orbitra renders into a full-blown web app: an "app store" storefront, a manifest and a service worker. Combined with the Web Push module it lets you grow a subscriber base right on the storefront and send them pushes — all by yourself, without external services (FCM is used only as the Android transport, Apple as the iOS transport; the keys and the database are yours).
This document covers: the PWA builder, app installation, domain binding, funnel instrumentation, the subscriber base, messages (manual and event-driven), the delivery cron and troubleshooting.
- A PWA is an ordinary row in the
landingstable (typelocal) whoseconfig_jsoncontains"pwa": true. The click router, stream rotation and cloaking treat it like any other local landing — no new types. - On save, the builder generates three files in the landing directory:
index.html(a Google Play-style storefront),manifest.webmanifestandsw.js. - The landing is served at
/lander/<slug>/— Chrome only offers installation inside the manifest scope, so a root URL is not used. - The click (a Click Log row + attribution cookies) is written before the redirect to the storefront, so the push subscription, funnel beacons and the hop to the offer are attributed to the real click.
Landings → Add PWA (or "Duplicate" on an existing one). The editor's main blocks:
| Block | What it configures |
|---|---|
| General | App name, slug, group, icon and screenshots (via the Content Gallery), category, developer, language, version, "updated" |
| Store | Description (with {value} macros), tags, rating histogram, reviews, "what's new", badges (verified, glow, live counter), light/dark theme, color scheme |
| Screen | The in-app screen shown after installation: type, title, text, button, image, custom HTML/JS |
| Actions | Install-button action: to the offer / to a campaign / to a URL / nowhere (action_target), auto-redirect timers |
| Push | Enable the push subscription on the app screen (push_enabled) |
| Domain | Domain binding (see §2) + the offer for the direct domain |
The Preview button renders the config draft with the production renderer — a live preview of the storefront and the screen (auto/iOS platform) without saving.
The storefront page sends one-pixel beacons that write into the click:
-
pwa_intent_at— a tap on "Install"; -
pwa_install_at— the app was installed (theappinstalledevent or the first standalone launch on iOS); -
pwa_open_at/pwa_open_count— re-openings of the installed app (at most once per 10 minutes per click); -
push_prompted_at/push_declined_at/push_subscribed_at— push subscription events; -
push_fail_reason— why a subscription did not happen (nokey/unsupported/insecure/denied/dismissed/error/timeout).
All the columns are visible in click details — the main funnel diagnostics tool: the tracker names the reason itself, no browser logs needed.
The subscription runs entirely inside the generated sw.js
(orbitraSubscribe): the page merely sends a message to the worker and heads off
to the offer. This is deliberate — on iOS the first, cold pushManager.subscribe can
take tens of seconds, and the subscription must not depend on how long
the user stays on the page. The worker also:
- re-subscribes by itself on
pushsubscriptionchange(endpoint key rotation on the push service's side, VAPID key update); - on every opening of the installed app quietly reconciles the subscription with the tracker settings (self-heal) — reopening the app repairs the subscription without a reinstall;
- shows incoming pushes and handles notification clicks
(focusing an existing window and navigating to
data.url).
The {vapid_public} placeholder in sw.js is substituted at file-serving time,
not at generation — a VAPID key change reaches already installed
apps without regenerating the static files.
Every renderer change bumps RENDERER_VERSION
(core/PwaLanding.php). On the next view of the landing the tracker compares
the version marker in the rendered static files and regenerates outdated
files by itself — no manual re-uploading to the server.
A domain can be bound to a PWA directly: a visit to https://domain.com/ opens
the storefront from the root, with no hop through a campaign and /lander/ — a clean install URL.
- The binding is configured in the builder (Domain block) or in the domain card.
- One domain — at most one PWA; one PWA can hang on several domains.
- Next to the domain an offer is selected (
pwa_offer_id): even without a campaign the "Offer" button on the storefront still leads to the offer via/?_lp=1with attribution. - Priorities: an explicit
?campaign_id=or campaign alias always beats the binding; the binding beatsindex_campaign_idon the root. - Organic visits (a cold visitor without a click cookie) are logged as
real clicks into the archived system campaign
orbitra-pwa-organic— it is created automatically and is not visible in the campaign list. Repeat visits with a live cookie do not spawn duplicates.
The Push Base page (panel → Push):
- Fill in the contact —
mailto:you@domain.comorhttps://…. Without a real contact key generation is blocked: Apple (web.push.apple.com) validates thesubclaim in the JWT and answers 403 BadJwtToken to placeholder addresses. - Click Generate keys. The keys are stored in the tracker settings
(
vapid_public_key/vapid_private_key/vapid_private_pem); encryption and signing are pure PHP + openssl (RFC 8291 + RFC 8292), no composer. - Key rotation asks for confirmation: existing subscriptions stop being valid, but every device quietly re-subscribes on the next opening of the app (self-heal); the base restores itself.
The diagnostics strip on the page shows: whether keys exist, the public key, the contact (with a warning if the default one is set), the age of the worker's last ping and the latest delivery error codes.
The push_subscriptions table: id, click, endpoint, country, language, UA, status
(active / expired), created, last shown. Capabilities:
- filters by status/country, search by endpoint and click_id, 50 rows/page pagination;
- operations: activate / deactivate / delete (hard deletion — the GDPR scenario);
-
CSV export of the whole base (
;separator: id;date;status;country;language;click_id;endpoint); - the Send button on a row — a test push to one subscriber bypassing the queue and the cron, instantly. Responses are decoded honestly: 403 — a hint about an invalid VAPID contact, 404/410 — the subscription is dead, anything else — the service's error code. This is the fastest way to test the whole pipe (keys → JWT → push service → device).
Subscribers multiply through reinstalls and re-subscriptions (the endpoint is changed by the push service) — old rows expire by themselves via 404/410 on send, no separate cleanup needed. Before a blast, check a subscriber's "freshness" by its click (click details show the landing and the install UA).
Messages are broadcast templates. Two kinds:
- Manual — what you send with a button;
-
Event-driven (kind
event) — sent by the cron automatically on an event:install(PWA install),lead,sale, delayed bydelay_secondsseconds from the event.
Message fields: title (≤250), body (≤400), icon (a URL, conveniently from the Gallery), click-through link, segment, the "active" flag.
Segments are computed from the subscriber's conversion counters at the moment of queueing:
| Segment | Who receives it |
|---|---|
all |
all active subscribers |
reg0 |
no registration |
reg1dep0 |
registered, no deposit |
reg1dep1 |
registered and deposited |
The body supports macros expanded at send time:
{variant1|variant2} (random choice, nesting ≤10), {Random=(X,Y)},
{subid} and other click macros — one message gives every recipient
its own text.
The panel itself sends nothing. The "Send now" button on a message row
queues (push_queue) one entry per active subscriber of the segment, with duplicate protection, and answers "Queued: N".
Delivery is done by the cron worker cli/push_cron.php — every minute:
* * * * * www-data php /var/www/orbitra/cli/push_cron.php --quiet >> /var/www/orbitra/var/logs/push.log 2>&1
The line is installed by install.sh automatically (marker # orbitra-push); check:
crontab -u www-data -l | grep orbitra-push. The worker: flock against parallel
runs, batches ≤300, retry after 300 seconds, 429 respects
Retry-After, 401/403 — one retry with a fresh JWT, 404/410 — the subscription
expires, 5xx/network — back into the queue. Event messages are triggered
by the same worker via a cursor in settings.
Queue health is shown in the strip above the message list: pending/done/failed and the time of the worker's last ping. If the ping is not ticking — the cron is not installed or not running, and delivery will not happen.
A 201 from the push service means "envelope accepted", not delivered. The push service cannot break the encryption (E2E): it answers 201 and silently drops the message if the device never reads it. The final judge is the phone alone. A monitored verification chain:
- Push Base → Subscribers → Send on the row → the "accepted" banner;
- on the device: the notification shade (the banner may have silently passed while the app was open);
- if 201 but silence — see §4.
No subscription at all — click details: push_fail_reason names the reason
(unsupported = no Push API — the Telegram/Chrome webview on iOS; insecure =
no HTTPS; nokey = VAPID not configured; denied = refused; timeout = slow
network — the subscription will arrive on the next open via self-heal).
iOS — Safari only, and only from the home screen. Web push on iOS works
under these conditions: iOS ≥ 16.4, the app added to the home screen specifically from
Safari (plain Safari has no Notification object — a subscription is never
created), a domain with a proper public certificate. An app added
to the home screen from Chrome (CriOS) gets no push guarantee: check the
installing browser by UA in click details (CriOS/… = Chrome,
Version/… = Safari). If it does not work — reinstall the icon from Safari.
403 BadJwtToken when sending — an invalid VAPID contact: Apple rejects
placeholder addresses (mailto:orbitra@localhost). Enter a real mailto:.
201 but not arriving for everyone everywhere — check that the server is on the latest
version (git log --oneline -1 via sudo -u www-data): early builds had
crypto-envelope defects (a header without keyid, a key schedule not per RFC 8291)
that the service still accepted with a 201. Then a control experiment:
desktop Chrome → install the PWA → Allow → Send: if it arrives on the desktop but not on the
iPhone — the problem is the device/channel (a VPN breaks the persistent
APNs connection; Chrome installs); if the desktop gets nothing either — write to support with the
error code from the diagnostics strip.
"Buttons don't click" after a panel update — almost always the browser
cache with an old index.html: hard-reload (Cmd+Shift+R) or a private
tab.
Silent cron — push_cron_last_ping_at is not ticking: the cron is not installed.
crontab -u www-data -l | grep orbitra-push; the log is var/logs/push.log.
-
tests/push_sender_test.php— cryptography: the ES256 signature verifies with openssl, the key ink=matches the signed key, the record decrypts byte-for-byte the way the browser does it (keyid read off the wire), a reference vector from the web-push library (npm) decrypts with our code, HKDF per RFC 5869, record size limits (body ≤ 4096 bytes — the Apple/FCM limit). -
tests/push_macros_test.php— macros and segment SQL. -
tests/push_base_test.php— the/push_subscribevalidator, migrations, static files. -
tests/push_cron_test.php— queue, retries, subscription expiry. -
tests/pwa_landing_test.php— SW handlers,{vapid_public}substitution at serve time, theService-Worker-Allowedheader. -
tests/domain_pwa_test.php— root rendering of a bound domain, cookies, organic clicks, binding priorities.
Вики собирается из папки docs/ репозитория. Правки — через pull request в docs/.
Getting started
Traffic
- Campaigns & streams
- Cloaking
- Landing pages
- Tracking Client (PHP)
- PWA & Web Push
- PWA Funnel
- Content gallery
Conversions & money
Analytics
Infrastructure
Management
For developers