Troubleshooting
Symptom first, cause second, fix third. Every entry corresponds to behaviour in the code.
"Permission denied" or no prompt appears
- The prompt never shows:
Notification.requestPermission()was called outside a user gesture, or the page is not a secure context. Move the call into a click handler; checkwindow.isSecureContext. getPermission()returns'denied': the user (or a browser policy) blocked notifications for the origin. It can only be re-enabled in browser settings (Chrome: padlock → Site settings → Notifications).- iOS Safari: push requires the site to be added to the Home Screen (iOS 16.4+) and permission granted from the installed web app.
- Firefox private window: push subscriptions are typically unavailable.
Service Worker registration errors
ServiceWorkerRegistration failed: The script has an unsupported MIME type ('text/html')
Your web server returned an HTML 404 page for the worker path. Serve the real file with a JavaScript MIME type.
Failed to register a ServiceWorker: The origin of the provided scriptURL
('https://cdn.example.net/sw.js') does not match the current origin ('https://example.com')
The worker must be on your own origin. Copy it there; do not load it from a CDN.
The Service Worker navigation preload ... / scope
A worker at /js/service-worker.js can only control /js/*. Move it to the root or send
Service-Worker-Allowed: / and register with serviceWorkerScope: '/'.
HTTPS requirements
Service Workers, Push and Notifications require a secure context: an HTTPS origin, or
http://localhost / 127.0.0.1 during development. A plain-HTTP staging host will fail
isSupported(). Mixed content (an HTTPS page loading the SDK over HTTP) is blocked by the browser.
Invalid VAPID keys
| Symptom | Cause | Fix |
|---|---|---|
503 SERVICE_UNAVAILABLE — "VAPID configuration is invalid" | Missing or malformed VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT | Re-run node scripts/generate-vapid-keys.mjs and set all three |
ApplicationServerKey is not valid in the browser | The public key was pasted with whitespace, wrong padding, or is not 65 bytes | Use the base64url value exactly as generated |
Publish fails with HTTP_401/HTTP_403 from the push service | VAPID key mismatch with existing subscriptions, or an invalid sub claim | Keep one key pair per deployment; VAPID_SUBJECT must be mailto: or https: |
| Subscriptions created before a key change stop working | Subscriptions are bound to the application server key | Subscribers must re-subscribe; do not rotate VAPID keys casually |
Invalid push subscriptions
INVALID_SUBSCRIPTION (400) means the payload was malformed: the endpoint is not HTTPS, the host is
not allow-listed, p256dh is not a 65-byte point, or auth is not 16 bytes. The SDK
produces these automatically, so a failure here usually means something else is posting to the API.
PUSH_ENDPOINT_NOT_ALLOWED means the endpoint host is not in ALLOWED_PUSH_HOSTS. Add the
host (for example a new vendor push service) and redeploy — or, for a private deployment, list the hosts you
trust explicitly.
When a push service itself returns 404/410 during delivery, the subscription is marked
invalid and excluded from future sends. That is expected churn, not an error you can fix.
CORS errors
Access to fetch at 'https://YOUR-API-HOST/v1/subscriptions' from origin 'https://example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.
- The origin is not registered, or does not match exactly (scheme, host and port all count). Registering
https://example.comdoes not authorizehttps://www.example.com. - You are calling a management endpoint (
/v1/notifications,/v1/sites/status) from a browser. Those routes deliberately send no CORS headers — sending is server-to-server only. - The
Originheader is missing: afile://page, a sandboxed iframe, or a cross-originno-corsrequest. Serve the page over HTTP(S). - You set
credentials: 'include'infetchOptions; this API never uses cookies and does not allow credentials.
Expired registrations
{ "success": false, "error": { "code": "SITE_EXPIRED", ... } }
No successful publish for INACTIVITY_DAYS (7 by default). Call
POST /v1/sites/reactivate with the same key, then publish. Subscription and notification data are
untouched. See Lifecycle.
For a registration that expires repeatedly, the cause is almost always a cron/job that was never scheduled, or a
publish job that fails silently. Alert on SITE_EXPIRED in your sender.
Failed push delivery
| Error code in delivery attempts | Meaning | Action |
|---|---|---|
HTTP_404, HTTP_410 | Endpoint gone — recorded as invalid | None; the subscription is retired |
HTTP_401, HTTP_403 | VAPID signature rejected | Check the key pair and VAPID_SUBJECT |
HTTP_413 | Payload too large for that push service | Shorten body/data; lower MAX_PUSH_PAYLOAD_BYTES |
HTTP_429 | Push service throttling | Reduce concurrency, retry later — recorded as failed, subscription kept |
HTTP_5xx | Push service outage | Retry; the subscription is kept |
TIMEOUT | No response within DELIVERY_TIMEOUT_MS | Raise the timeout or lower concurrency |
NETWORK_ERROR | DNS/TLS failure reaching the endpoint | Usually transient |
ENCRYPTION_FAILED | Stored key material cannot encrypt | Subscriber must re-subscribe |
AUTH_DECRYPT_FAILED | The encrypted auth secret no longer decrypts (key changed) | Subscriber must re-subscribe |
A 201 from the push service means "accepted", not "shown". If notifications are accepted but never
appear, check: OS-level notification settings for the browser, site-level mute, Do Not Disturb, browser "quiet
mode", and whether the user is on a browser whose push service silently drops the endpoint.
Documentation returns 404
The docs are generated at build time. Run npm run build:assets and redeploy; a fresh clone that was
never built (or a deploy that skipped the prepare step) will not have src/generated/assets.js.
Database and state errors
| Symptom | Meaning |
|---|---|
no such table: sites | The instance you are calling has no schema applied. This is an operator problem — self-hosters apply the migrations in the repository. |
DATABASE_ERROR: too many SQL variables | A delivery batch was built larger than the database allows; the operator should lower DELIVERY_PAGE_SIZE. |
Publishes stay processing | The request was cut short and the background sweep has not picked it up yet. The sweep runs every 15 minutes and resumes mid-flight notifications. |
503 with "database": "unavailable" | From /health: the instance cannot reach its database. Nothing your integration can fix — retry with backoff. |
Useful commands
# what to send the operator (or your own log search)
request id: x-request-id response header
endpoint: the path you called
time: UTC timestamp of the failure
outcome: error code + message, verbatim