Push
Push API
The WRNexus Web Push HTTP API — the public install surface (SDK loader, service-worker template, config), sessionless subscription / preference / tracking endpoints, and the workspace-scoped site, campaign, and segment management endpoints.
The Push API has two surfaces:
- Public (sessionless) — the install assets, config, subscription
registration, preferences, and first-party tracking. These are reached by the
on-page SDK and the service worker from
arbitrary customer origins. They take no session and no API key: each is
tenant-scoped by the non-secret site key, and every response carries
Access-Control-Allow-Origin: *. - Authenticated (workspace-scoped) — site, campaign, and segment management
plus analytics. These use your dashboard session (or an
API key) and are
filtered on the caller’s active workspace. Mutations require the
owneroradminworkspace role.
Base URL
https://api.wrnexus.com
The public install assets live under /push/*; everything else lives under
/api/push/*.
Public install surface
GET /push/sdk.js
The on-page SDK loader, served verbatim and cacheable (max-age=600). Loaded via
<script src="…/push/sdk.js" data-site-id="SITE_KEY">. See
Install the snippet.
GET /push/sw.js
The service-worker template you copy to your own origin. Short cache so you always grab the latest. See Service worker setup.
GET /push/config
Resolve a site’s runtime configuration from its public site key. Called by the SDK; needs no per-site build.
| Query | Type | Notes |
|---|---|---|
siteKey |
string | Required. The site’s publicKey. Aliases: publicKey, siteId, site_id. |
curl "https://api.wrnexus.com/push/config?siteKey=SITE_KEY"
Response 200 OK:
{
"ok": true,
"siteKey": "SITE_KEY",
"vapidPublicKey": "BPx…",
"apiBase": "https://api.wrnexus.com",
"domain": "your-domain.example",
"icon": "https://your-domain.example/icon.png"
}
An unknown or inactive site returns 404 with { "ok": false, "code": "UNKNOWN_SITE" }.
Public subscription endpoints
POST /api/push/subscriptions
Register (or re-activate) a browser subscription. The owning tenant is resolved
from the site identified by key — the client never supplies an org/workspace
id. A re-subscribe with the same endpoint reactivates the existing row and
shallow-merges the incoming metadata.
The body is read as raw bytes, so the SDK sends it as a CORS-simple text/plain
request (no preflight). The JSON shape:
| Field | Type | Notes |
|---|---|---|
key |
string | Required. The site key. Aliases: siteKey, publicKey. |
endpoint |
string | Required. The W3C PushSubscription.endpoint. |
keys |
object | { p256dh, auth } (base64url). Required unless sent flat. |
p256dh / auth |
string | Flat alternative when not nested under keys. |
metadata |
object | Optional. Page/visitor metadata, shallow-merged on re-subscribe. |
curl https://api.wrnexus.com/api/push/subscriptions \
-H "Content-Type: text/plain;charset=UTF-8" \
--data '{
"key": "SITE_KEY",
"endpoint": "https://fcm.googleapis.com/fcm/send/abc…",
"keys": { "p256dh": "BMx…", "auth": "k9…" },
"metadata": { "url": "https://your-domain.example/", "lang": "en-US" }
}'
Response 201 Created: { "ok": true, "id": "…" }
| Error | HTTP | Meaning |
|---|---|---|
INVALID_BODY |
400 | Malformed JSON. |
INVALID_SUBSCRIPTION |
400 | key, endpoint, p256dh, or auth missing. |
UNKNOWN_SITE |
404 | No site for that key. |
SITE_INACTIVE |
403 | The site is not accepting subscriptions. |
POST /api/push/subscriptions/unsubscribe
CORS-simple unsubscribe keyed by (siteKey, endpoint) — the SDK calls this after
it unsubscribes the browser’s PushSubscription. Deactivates the matching
subscription (soft delete) and records an unsubscribed event. Idempotent:
an already-removed subscription still returns success.
| Field | Type | Notes |
|---|---|---|
key |
string | Required. Site key. Aliases: siteKey, publicKey. |
endpoint |
string | Required. The subscription endpoint. |
curl https://api.wrnexus.com/api/push/subscriptions/unsubscribe \
-H "Content-Type: text/plain;charset=UTF-8" \
--data '{ "key": "SITE_KEY", "endpoint": "https://fcm.googleapis.com/fcm/send/abc…" }'
Response 200 OK: { "ok": true }
DELETE /api/push/subscriptions/{id}
Unsubscribe by the server-side subscription id. An optional key query parameter
must match the subscription’s site (an extra guard).
curl -X DELETE "https://api.wrnexus.com/api/push/subscriptions/SUB_ID?key=SITE_KEY"
Response 200 OK: { "ok": true, "id": "SUB_ID" }. A missing subscription
returns 404; a mismatched key returns 403.
Public preferences endpoints
Per-topic opt-in state, resolved by the (siteKey, endpoint) pair. A campaign
tagged with a topic suppresses subscribers who have opted out of it.
GET /api/push/preferences
| Query | Type | Notes |
|---|---|---|
key |
string | Required. Site key. |
endpoint |
string | Required. The subscription endpoint. |
{
"ok": true,
"preferences": [
{ "topic": "order-updates", "optedIn": true },
{ "topic": "promotions", "optedIn": false }
]
}
POST /api/push/preferences
Set preferences. Accepts a bulk topics map or a single topic + optedIn.
Topics are trimmed and bounded to 100 characters; empties are dropped.
| Field | Type | Notes |
|---|---|---|
key |
string | Required. Site key. |
endpoint |
string | Required. The subscription endpoint. |
topics |
object | Bulk form: { "promotions": false }. |
topic + optedIn |
string + bool | Single-topic form. |
curl https://api.wrnexus.com/api/push/preferences \
-H "Content-Type: text/plain;charset=UTF-8" \
--data '{ "key": "SITE_KEY", "endpoint": "https://…", "topics": { "promotions": false } }'
Response 200 OK: { "ok": true, "preferences": [ … ] }. NO_TOPICS (400)
when no valid topic is supplied; UNKNOWN_SUBSCRIPTION (404) when the pair does
not resolve.
First-party tracking endpoints
Tracking links carry an HMAC-signed token holding (subscriptionId, campaignId?, url?), so the endpoints need no lookup table and nobody can forge
an event for another subscription or turn the click redirect into an open
redirector — the destination is fixed inside the signature. Tokens are valid for
one year (the life of a notification in the browser). The send pipeline builds
these URLs and embeds them in each push payload; the service
worker and the click itself call them.
| Endpoint | Method | Behaviour |
|---|---|---|
/api/push/track/click/{token} |
GET |
Records a clicked event and 302-redirects to the signed destination (http(s) only). |
/api/push/track/delivered/{token} |
POST |
Records a delivered receipt (fired by the worker on push). |
/api/push/track/open/{token} |
POST |
Records an opened/show receipt. |
/api/push/track/unsubscribe/{token} |
POST |
Deactivates the subscription and records unsubscribed. |
Recording is best-effort and idempotent: an event resolves the owning tenant
from the subscription’s own row, appends a push_events row, advances the
matching per-recipient send row + campaign counters once per transition
(guarded on the timestamp column being NULL), and freshens the subscriber’s
lastSeenAt. An invalid or expired click token returns 410 Gone with a small
HTML page; the other receipts always return { "ok": true }.
Site management
Workspace-scoped. Creating a site provisions its VAPID keypair and site key.
GET /api/push/sites · GET /api/push/sites/{id}
List or read the workspace’s sites. The site object:
{
"id": "…",
"name": "Acme Store",
"domain": "your-domain.example",
"publicKey": "SITE_KEY",
"iconUrl": "https://…/icon.png",
"defaultUrl": "https://your-domain.example/",
"status": "active",
"frequencyCap": { "max": 3, "windowHours": 24 },
"quietHours": { "start": 22, "end": 7, "tz": "Asia/Kolkata" },
"vapidPublicKey": "BPx…",
"createdAt": "2026-06-18T14:30:00.000000",
"updatedAt": "2026-06-18T14:30:00.000000",
"deletedAt": null
}
POST /api/push/sites
Create a site (role owner/admin). Returns the site object including its
freshly minted vapidPublicKey with 201 Created.
| Field | Type | Notes |
|---|---|---|
name |
string | Required. Display name. |
domain |
string | Required. The site’s domain. |
iconUrl |
string | Default notification icon. |
defaultUrl |
string | Default click destination. |
vapidSubject |
string | VAPID sub claim (a mailto: or origin). |
frequencyCapMax |
int | Max notifications per subscriber per window. 0/null ⇒ unlimited. |
frequencyCapWindowHours |
int | Window length (default 24). |
quietHoursStart / quietHoursEnd |
int | Local-clock hours [start, end) for the quiet window. |
quietHoursTz |
string | IANA timezone for quiet hours (default UTC). |
PUT /api/push/sites/{id} · DELETE /api/push/sites/{id}
Update or soft-delete a site (role owner/admin).
Subscribers
GET /api/push/subscribers
List the workspace’s subscriptions.
{
"subscribers": [
{
"id": "…",
"siteId": "…",
"endpoint": "https://fcm.googleapis.com/fcm/send/abc…",
"active": true,
"userAgent": "Mozilla/5.0 …",
"metadata": { "url": "https://your-domain.example/", "lang": "en-US" },
"lastSeenAt": "2026-06-18T14:30:00.000000",
"unsubscribedAt": null,
"createdAt": "2026-06-10T09:00:00.000000"
}
],
"total": 1
}
Campaigns
GET /api/push/campaigns · GET /api/push/campaigns/{id}
List or read campaigns. The campaign object carries live counters:
{
"id": "…",
"siteId": "…",
"segmentId": null,
"name": "Weekend sale",
"title": "20% off this weekend",
"body": "Tap to shop the sale.",
"iconUrl": "https://…/icon.png",
"imageUrl": "https://…/hero.jpg",
"targetUrl": "https://your-domain.example/sale",
"topic": "promotions",
"status": "scheduled",
"stats": {
"recipients": 1200,
"sent": 1200,
"delivered": 1150,
"clicked": 240,
"opened": 300,
"failed": 12,
"unsubscribed": 8
},
"scheduledAt": "2026-06-20T09:00:00.000000",
"sentAt": null,
"canceledAt": null,
"createdAt": "2026-06-18T14:30:00.000000",
"updatedAt": "2026-06-18T14:30:00.000000",
"deletedAt": null
}
POST /api/push/campaigns · PUT /api/push/campaigns/{id} · DELETE /api/push/campaigns/{id}
Create, update, or soft-delete (role owner/admin).
| Field | Type | Notes |
|---|---|---|
name |
string | Required. Internal name. |
siteId |
string | Required. The owning site. |
title / body |
string | Notification title and body. |
iconUrl / imageUrl |
string | Notification icon / hero image. |
targetUrl |
string | Click destination (wrapped in a signed tracking redirect at send time). |
topic |
string | When set, subscribers opted out of this topic are suppressed. |
segmentId |
string | Target a saved segment. |
audience |
object | Ad-hoc audience predicates (alternative to segmentId). |
POST /api/push/campaigns/{id}/send-test
Resolve the test recipients and preview the rendered payload (role
owner/admin).
{
"ok": true,
"campaignId": "…",
"attempted": 2,
"delivered": 2,
"failed": 0,
"deactivated": 0,
"results": [ … ],
"payload": { "title": "…", "body": "…", "clickUrl": "…" }
}
POST /api/push/campaigns/{id}/schedule
Schedule a send (role owner/admin).
| Field | Type | Notes |
|---|---|---|
scheduledAt |
string | Required. RFC3339 timestamp for the send. |
Returns the updated campaign object.
Segments
A segment is a reusable, named audience: an AND-list of whitelisted predicates evaluated server-side over the workspace’s subscriptions (and, for behavioural rules, its events).
| Endpoint | Method | Purpose |
|---|---|---|
/api/push/segments |
GET / POST |
List / create. |
/api/push/segments/{id} |
GET / PUT / DELETE |
Read / update / delete. |
/api/push/segments/{id}/preview |
GET |
Resolve the size of a saved segment. |
/api/push/segments/preview |
POST |
Resolve the size of an ad-hoc predicate set. |
The segment object:
{
"id": "…",
"siteId": "…",
"name": "Engaged mobile users",
"description": "Clicked in the last 30 days, on mobile",
"predicates": { "…": "…" },
"rules": "clicked in last 30d AND device = mobile",
"size": 842,
"createdAt": "2026-06-18T14:30:00.000000",
"updatedAt": "2026-06-18T14:30:00.000000"
}
POST body: name (required), description, siteId, and predicates (a
JSON object of whitelisted field/op/value rules — field and operator names are
matched against a fixed table and every value is bound, never interpolated).
Analytics
GET /api/push/analytics
Aggregate engagement for the workspace over a rolling window.
| Query | Type | Notes |
|---|---|---|
siteId |
string | Optional. Scope to one site. |
days |
int | Window length, clamped to 1–365 (default 30). |
The response includes a subscriber snapshot (total, active, new-in-window), a
delivery funnel (sent ≥ delivered ≥ clicked, plus failed / unsubscribed),
first-party event totals, daily opt-ins, browser/device breakdowns (parsed from
the stored user-agent), and the top campaigns by reach.
Error contract
Public surfaces return { "ok": false, "code": "…", "message": "…" } with the
permissive CORS header. Authenticated surfaces use the standard WRNexus error
envelope:
{ "code": "FORBIDDEN", "message": "This action requires the owner or admin role." }
| Code | HTTP | Description |
|---|---|---|
UNAUTHENTICATED |
401 | Missing or invalid session / API key. |
FORBIDDEN |
403 | Caller lacks the owner/admin role for a mutation. |
INVALID_BODY |
400 | Malformed request body. |
INVALID_SUBSCRIPTION |
400 | Required subscription fields missing. |
INVALID_SITE_KEY |
400 | siteKey missing on /push/config. |
UNKNOWN_SITE |
404 | No site for the supplied key. |
SITE_INACTIVE |
403 | Site not accepting subscriptions. |
UNKNOWN_SUBSCRIPTION |
404 | (siteKey, endpoint) did not resolve. |
NO_TOPICS |
400 | No valid topic in a preferences write. |
NOT_FOUND |
404 | Site / campaign / subscription not found. |
See also
- Web Push overview — how the pieces fit together.
- Install the snippet and Service worker setup.
- Web Push SDK — the browser API that calls these endpoints.
- Troubleshooting — scope errors, 410 deactivation, denied permission.