Skip to content

Web push errors, and what to do about each

Every status a web push service returns, from 201 to 410 Gone, 403 for VAPID key problems, 413 payload too large and 429, with what to delete, fix or retry.

PushBy Rushi Patel, Senior iOS developerUpdated 2 November 20266 min read

A web push goes from your server to a push service run by the browser maker: FCM for Chrome, Edge and other Chromium browsers, Mozilla’s autopush for Firefox, Apple’s service for Safari. The URL in the subscription’s endpoint tells you which. They all follow the same protocol (RFC 8030, with VAPID in RFC 8292), so they answer with the same status codes. Here is each one and what to do.

Every web push status code

Status Meaning What to do Retry
201 Created Accepted by the push service Nothing. It is not proof of display
400 Bad Request A header is wrong: TTL missing, a bad Urgency, a Topic over 32 characters, bad encryption headers Fix the request No
401 Unauthorized No VAPID Authorization header Sign every request No
403 Forbidden VAPID is invalid: wrong key pair, bad signature, exp out of range, wrong aud See below No
404 Not Found The subscription has expired Delete the subscription No
410 Gone The subscription was removed (unsubscribed, permission revoked) Delete the subscription No
413 Payload Too Large The encrypted body is over the service’s limit Send less No
429 Too Many Requests Rate limited Wait for Retry-After, then retry Yes
5xx The push service failed Back off and retry Yes

404 and 410: delete the subscription

The RFC uses 404 for an expired subscription; in practice push services also answer 410 when the user unsubscribed or blocked notifications. Treat both the same way: remove the row and stop sending. A sender that keeps posting to dead endpoints wastes requests and, at volume, gets rate limited.

const res = await webpush.sendNotification(sub, payload).catch((e) => e);
if (res.statusCode === 404 || res.statusCode === 410) await db.subscriptions.delete(sub.endpoint);

To see a 410 yourself, call registration.pushManager.getSubscription() in the browser, then subscription.unsubscribe(), and send to the old endpoint.

403 Forbidden: VAPID problems

A subscription is tied to the public key the page passed as applicationServerKey in pushManager.subscribe(). Every push to it must be signed with the matching private key. The usual ways this breaks:

  1. The keys changed. Someone ran web-push generate-vapid-keys again, or staging and production have different keys and share a database. Every old subscription now returns 403. Keep one key pair per site for ever, or resubscribe every user.
  2. The JWT’s exp is wrong. It must be in the future and at most 24 hours ahead. A server clock that is off, or an exp in milliseconds instead of seconds, fails.
  3. aud is wrong. It must be the origin of the endpoint, such as https://fcm.googleapis.com, not your site and not the full endpoint URL.
  4. sub is missing or malformed. It must be a mailto: address or an https: URL. Apple’s service is strict about it.

The header looks like this in the current scheme:

Authorization: vapid t=<JWT signed ES256>, k=<your public key, base64url>
TTL: 86400
Content-Encoding: aes128gcm

400: the headers

  • TTL is required. It is how many seconds the service keeps the message for an offline device; 0 means deliver now or drop. Services may shorten it and tell you in the response’s TTL header.
  • Urgency is very-low, low, normal or high. Use high for messages the user is waiting for.
  • Topic lets a newer message replace a waiting one with the same topic. At most 32 URL-safe base64 characters.
  • The body must be encrypted with the subscription’s p256dh and auth keys (aes128gcm). Sending plain JSON fails.

413: payload size

Push services must accept an encrypted body of at least 4,096 bytes. Encryption adds a header and padding, so keep your JSON under about 3,900 bytes. For more, send an id and let the service worker fetch the rest:

self.addEventListener("push", (e) => {
  const { id, title } = e.data.json();
  e.waitUntil(self.registration.showNotification(title, { data: { id } }));
});

201, and still nothing on screen

The push service accepted it; the rest happens in the browser:

  • The service worker has no push listener, or does not call showNotification() inside event.waitUntil(). Chrome then shows its own “This site has been updated in the background” notice.
  • The browser was closed past the TTL, or the computer was asleep.
  • On iPhone and iPad, web push only works for a site added to the Home Screen. See web push on iOS.
  • The user turned notifications off for the browser in the operating system’s settings, or Focus or Do Not Disturb is on.

With SignOps

SignOps Push makes the VAPID keys for your app and keeps them, so they never change under your subscriptions. It encrypts each message, signs it per push service, removes a subscription on 404 or 410 and posts device.removed to your webhook, retries 429 and 5xx, and records any other answer as the push’s failure reason in the dashboard. Setup is in the web push guide, and the web push tester sends a push to this browser with no account.

Sources: IETF RFC 8030: Generic Event Delivery Using HTTP Push, IETF RFC 8292: VAPID, web.dev: The Web Push Protocol.

Frequently asked questions

More questions? Email us and the answer goes on this page.

hello@signops.dev
What does 410 Gone mean when sending a web push?

The push subscription no longer exists. The user unsubscribed, revoked the notification permission, cleared the site's data, or the browser expired the subscription. Delete it from your database; the browser will make a new one the next time your page subscribes.

Why does web push return 403 Forbidden?

The push service rejected your VAPID authentication. Most often the VAPID key pair you sign with is not the one whose public key the page passed as applicationServerKey when it subscribed, or the JWT's exp is more than 24 hours ahead or already past, or the aud claim is not the push service's origin.

What is the maximum web push payload size?

Push services must accept at least 4,096 bytes of encrypted body. After encryption overhead that leaves roughly 3,990 bytes for your JSON. Anything larger may get 413 Payload Too Large. Send an id and fetch the rest from your server in the service worker if you need more.

Is a 201 Created response proof the notification was shown?

No. 201 means the push service accepted the message. Delivery depends on the device being online before the TTL runs out, and display depends on your service worker calling showNotification in its push event.

Send your first push today.

Free for 1,000 push devices, 100 approvals and 1,000 passkey sign-ins a month, and 500 email codes to try. No card until you pass a free amount.