Web Push API

The big deal about Progressive Web Applications (PWA) is that they have the capability to receiving a information while the browser is closed (via Push API subscription) and wake up the service worker to perform a task, such as using the Web Notification API. In fact – if you don’t use the Notification API when handling a push event, the web browser may alert the user stating that something is happening in the background. The Push API requires key generation, a bit of server-side logic, and a database to store the subscriptions.

VAPID

Push API uses a Voluntary Application Server Identification (VAPID) for Web Push. In this case, our back-end website (api.periplux.io) is the Application Server that originates the push. VAPID is an asymmetric set of public and private keys often referred to as VAPID Keys or application server keys.

VAPID was not in the original standard. It allows a standard method for authenticating push subscriptions. Authentication used to be done in various methods by different services. VAPID is used to cryptographically sign all pushed data sent to the client. I’m more familiar with certificates used for authentication, code signing, email signing, and SSL connections on websites. The VAPID is a bit different.

  • Not issued by a trusted third party (certificate authority)
  • Does not expire
  • Can not be revoked (sort of)

Although you can’t check to see if a VAPID key has been publicly revoked, you are able to personally unsubscribe form a service using that key. Before VAPID, anyone who got ahold of subscription URI’s could send messages to all of your subscribers. Now – without the VAPID private key, all subscribers devices should reject messages that can not be authenticated with the public key.

One concern I have is that without a revocation, a website that had both its private key and subscription endpoints hacked would leave all of its users vulnerable to unwanted push notifications. It would be in the operators best interest to automatically unsubscribe everyone the next time that they visit the site, and send a push notification instructing them to do so. Rather than asking the client to visit the website, the service worker could be setup to automatically unsubscribe from push notifications if a specific command came through instructing it to do so.

We may create public and private VAPID keys. We can generate the keys ourselves using OpenSSL.

# Generate an elliptic curve key pair
# (secp256r1 / P-256)
openssl ecparam \
  -genkey \
  -name prime256v1 \
  -out vapid_key_pair.pem

# extract public key
# (last 65 bytes of binary)
# as URL-safe base64
openssl ec \
  -in vapid_key_pair.pem \
  -pubout \
  -outform DER \
  | tail -c 65 \
  | base64 \
  | tr -d '=' \
  | tr '/+' '_-' \
  > vapid_key_public.b64

# extract private key
# (32 bytes after 8 bytes of binary)
# as URL-safe base64
openssl ec \
  -in vapid_key_pair.pem \
  -outform DER \
  | tail -c +8 \
  | head -c 32 \
  | base64 \
  | tr -d '=' \
  | tr '/+' '_-' \
  > vapid_key_private.b64

We end up with three text-based files.

  • vapid_key_pair.pem containing both the public and private key
  • vapid_key_public.b64 containing the base64url encoded public key
  • vapid_key_private.b64 containing the base64url encoded private key

For the purposes of education, these will be the keys discussed in this article. Although the length and encoding are still accurate, I’ve malformed them a bit, so they should not be valid keys. Do not use them since everyone else has access to them.

TypeKey
Private KeypCszHQEXLy_Wy4j0nVw2fauFyvPEvX3Y9DWgWoPJe-A
Public KeyBdJbY4uC3krhWwwtksGXhMqfHqE9vEj8ZzPLFdqPKxMkuK82Y8bcp59VtWr-7y7bSMN-Jos0Vfx-jntFp3RHRrc

We need to ensure that the public key is made available to the service worker in our web application. We also need to store the private key securely for the back-end api to use.

For now, lets create a push folder and provide a .htaccess file containing the VAPID keys as environment variables. We can setup secrets on our build server and build the .htaccess file during deployment. Visitors to our site are unable to view .htaccess files. However, anyone with access to the FTP site or file manager interface could navigate to the file and see its contents. In addition, a developer or package could capture all environment variables and ship them off to somewhere else. We will need to revisit where the keys should be stored to tighten security.

on:
  push:
    branches:
      - develop

name: Development
jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/develop'
    timeout-minutes: 1 # Don't let FTP hang

    steps:
      - name: Get latest code
        uses: actions/checkout@v4

      - name: Push API Configuration
        run: |
          touch src/push/.htaccess
          echo RewriteOptions inherit >> src/push/.htaccess
          echo SetEnv VAPID_PUBLIC_KEY \"${{ vars.VAPID_PUBLIC_KEY }}\" >> src/push/.htaccess
          echo SetEnv VAPID_PRIVATE_KEY \"${{ secrets.VAPID_PRIVATE_KEY }}\" >> src/push/.htaccess
          cat src/push/.htaccess

      - name: Deploy via FTP
        uses: sebastianpopp/ftp-action@releases/v2
        with:
          host: ${{ vars.DEV_FTP_HOST }}
          user: ${{ secrets.DEV_FTP_USER }}
          password: ${{ secrets.DEV_FTP_PASSWORD }}
          forceSsl: 'true'
          localDir: 'src'

We can also create a php script as an endpoint to request the public key as https://dev-api.periplux.io/web-push/public-key

<?php
header('Cache-Control: max-age=600');
echo getenv('VAPID_PUBLIC_KEY');

Subscription Request

Now that we have an endpoint to fetch the public key from, our service worker can request the key and initiate a subscription request. The other thing we can do to cut down on server traffic is to include the public key in our client. Let’s setup a .env file to store a few values.

VITE_VAPID_PUBLIC_KEY=BdJbY4uC3krhWwwtksGXhMqfHqE9vEj8ZzPLFdqPKxMkuK82Y8bcp59VtWr-7y7bSMN-Jos0Vfx-jntFp3RHRrc
VITE_API_DOMAIN=dev-api.periplux.io

Make sure to setup your .gitignore file to ignore .env files. Using vite, we can access environment variables prefixed with VITE_*

You can automate the subscription to the push API – but before doing so, the the end-user MUST have granted permission to Notifications. If they disable notification before you subscribe them to the push subscription, you’ll get an error:

Subscription failed: NotAllowedError: Registration failed – permission denied

Since we are all developers here, let’s bypass the check on notifications and setup a component

  • Detects if the end-user is subscribed
  • Subscribe manually
  • Unsubscribe manually
  • Auto-unsubscribe if the public key changed
import { useEffect, useState } from 'react';

const PushStatus = () => {
  const [canSubscribe, setCanSubscribe] = useState(false);
  const [subscription, setSubscription] =
    useState<PushSubscription>();
  const [registration, setRegistration] =
    useState<ServiceWorkerRegistration>();
  useEffect(() => {
    navigator.serviceWorker.ready
      .then(async (registration) => {
        const subscription =
          await registration.pushManager.getSubscription();
        setRegistration(registration);
        if (subscription) {
          if (
            import.meta.env.VITE_VAPID_PUBLIC_KEY !==
            bytesToUrlBase64(
              subscription.options.applicationServerKey,
            )
          ) {
            // VAPID changed! Unsubscribe from unused key
            await subscription.unsubscribe();
            // TODO: Tell server to cleanup
            setCanSubscribe(true);
          } else {
            setSubscription(subscription);
          }
        } else {
          setCanSubscribe(true);
        }
      })
      .catch((error) => {
        console.error(`Service Worker Error: ${error}`);
      });
  }, []);
  const handleSubscribe = () => {
    registration?.pushManager
      .subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToBytes(import.meta.env.VITE_VAPID_PUBLIC_KEY),
      })
      .then((subscription) => {
        // TODO: Tell server how to push to us
        setSubscription(subscription);
      })
      .catch((error) =>
        console.error(`Subscription failed: ${error}`),
      );
  };

  const handleUnsubscribe = () => {
    subscription
      ?.unsubscribe()
      .then((successful) => {
        console.log(`Successful: ${successful}`);
        // TODO: tell server to cleanup
        setSubscription(undefined);
        setCanSubscribe(true);
      })
      .catch((error) =>
        console.error(`Unsubscribe failed: ${error}`),
      );
  };

  return (
    <div>
      <h4>Push</h4>
      <p>Public Key: {import.meta.env.VITE_VAPID_PUBLIC_KEY}</p>
      {subscription ? (
        <div>
          <p>Endpoint: {subscription?.endpoint}</p>
          <p>Expiration: {subscription?.expirationTime}</p>
          <p>
            Application server key:{' '}
            {bytesToUrlBase64(
              subscription?.options.applicationServerKey,
            )}
          </p>
          <p>
            User visible only:{' '}
            {`${subscription?.options.userVisibleOnly}`}
          </p>
          <button onClick={handleUnsubscribe}>
            Unsubscribe
          </button>
        </div>
      ) : canSubscribe ? (
        <button onClick={handleSubscribe}>Subscribe</button>
      ) : null}
    </div>
  );
};

const urlBase64ToBytes = (encodedBase64: string) => {
  let length = encodedBase64.length;
  if (length % 4 !== 0) length += 4 - (length % 4);
  const base64 = encodedBase64
    .padEnd(length, '=')
    .replace(/\-/g, '+')
    .replace(/_/g, '/');
  const raw = atob(base64);
  const bytes = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; i++) {
    bytes[i] = raw.charCodeAt(i);
  }
  return bytes;
};
const bytesToUrlBase64 = (
  bytes?: ArrayBuffer | null,
): string => {
  if (!bytes) return '';
  const numbers = Array.from(new Uint8Array(bytes));
  const raw = String.fromCharCode.apply(null, numbers);
  const base64 = btoa(raw);
  const urlEncode = base64
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
  return urlEncode;
};
export default PushStatus;

VAPID keys are both base64 encoded and url encoded, so a few helper functions were added to encode/decode the public keys for comparison.

Example of push subscription

Each time the user subscribes, they receive a different end-point regardless if they use the same public key, browser, and origin/domain.

If the user disabled notifications and you try to unsubscribe a user (ie public api doesn’t match), you’ll get “Success = false”

// User disabled Notifications but has subscription
subscription?.unsubscribe().then(successful => {
  console.log(successful); // false
});

The browser still discards the subscription information. If they re-enable notifications, you’ll need to re-subscribe them for push.

What is userVisibleOnly? This is an agreement that you are making with the end user letting them know that you will not be doing anything behind the scenes without a visible notification, such as tracking their location. Currently you are required to pass in a value of true. This is why the Notification service is required to have permission – otherwise the app would only have silent pushes.

Push

You’ve got a URL and a private key. How do you actually do the push? You would typically use a backend server to communicate with the push service. A push can come from any server at any time so long as they have the VAPID keys.

There are several libraries available that take care of everything under the hood in order to send a web push to the end-user’s push service endpoint. In a nut shell, here is an overview of what is happening.

  • An Authorization WebPush header is created with a JSON Web Token (JWT) that has some info
    • Subject (email address)
    • Audience (domain name)
    • Expiration
  • The JWT is signed using the VAPID Private key. This way, it can be confirmed that the information in the header was from you, and not manipulated.
  • A symmetric key is generated and used to encrypt the data.
  • The end-users public key is used to encrypt the symmetric key

It sounds simple – but the actual implementation is not. We have many steps involved that not only encrypt the data, but also ensure that only the end-user can decrypt that data, they can verify that we are the ones that sent the data, and that no one else has modified the data. It’s similar to how SSL works on websites using the https protocol.

Cryptographic Message Overview

When the client first subscribed to our push service, a public key exchange took place. This key exchange took place within a secured website using the https protocol in which the application server has already been trusted either through a third party certificate authority, or from a manually trusted self-signed certificate. The client now has our VAPID public key. The clients public key from the subscription was available to us, and should have been saved in our database. These specific keys were asymmetric keys. That means that there are separate keys used in the encryption and decryption of data.

With each push request that we initiate, we generate a new symmetric key (usually AES; Advanced Encryption Standard) to encrypt the payload during the message encryption phase. Unlike asymmetric public/private keys, there is only one key often referred to as a shared secret key. The key that is used to encrypt data is also used to decrypt it. The client doesn’t have this key. We need to securely send that key to the client so that they can decrypt the payload. The symmetric key is small enough that it can be encrypted with the clients public key and sent along with the encrypted payload. This is known as key wrapping. This ensures that only the client can decrypt the symmetric key using their asymmetric private key. The last part on our end is to digitally sign the message with our private key. This allows the client to detect if anyone else has tampered with our original message, similar to a cyclic redundancy check (CRC) code indicates if data has been corrupted, except that we have a fairly large hash rather than just 1 to 4 bytes.

During the message decryption phase, the client receives the encrypted message. They check the signature against the public key to verify that the contents have not been altered. Then they decrypt the symmetric key using their asymmetric private key. The symmetric key is then used to decrypt the message.

Why use symmetric keys? They are great at creating new keys fast and at scale. But the creation of the VAPID was fast… Yes! To humans the process these days is fast. However, at scale, the creation is slow. Why not encrypt the payload with the clients public key? Payloads are usually too large. The Public/Private keys are only great at encrypting a few bytes of data (ie – a password). With RSA encryption, you are limited to encrypting no more than the key size (2048 bits) and may have to adjust for padding (~245 bytes). They also take a lot of computing power in order to encrypt data. This is because they are working with excessively large prime numbers. Most computers these days work with 64 bit integers natively and encryption algorithms are using 2048 bit integers. It’s a significantly large difference in computational overhead to perform mathematical operations. A public/private key pair usually represent two prime numbers that can be used to either encrypt or decrypt a message one way. I can’t decrypt a message that I created with a public key. Part of the strength of the security in protecting these messages is that it is very difficult for a computer to work out the prime numbers used to create the message.

On of the things that wasn’t mentioned yet is that a third party is involved in our communication with the client. For google chrome browsers, you’ll see that the endpoint url is https://fcm.googleapis.com/fcm/send/. We may simply trust that googleapis is valid. Let’s not get complacent though. Who is to say that Google couldn’t modify our messages, or that the client could send us any endpoint. Maybe an Internet service provider (ISP) was able to redirect the domain name to a different IP address. This is why both encryption and digital signatures are needed. It’s to prevent the man in the middle attack – whether it’s a trusted API service or a virus/malware modifying packets on the wire.

Digging Deep

The world of cryptography is a deep rabbit hole to go down. There are many various algorithms and protocols involved, that’s its difficult to tell if you are on the correct path sometimes as well as various encodings and formats to ensure the data can be transferred and read properly. I got a Client-side JavaScript version mostly working, but CORS got in the way of going much further. I started implementing it in PHP as well, but often ran into problems with restrictions on various features available in openssl on my host. So much effort was being put into figuring out how to do a vanilla PHP solution from scratch that I stepped back and decided to find a library where someone already figured out how to implement the solution end-to-end.

manishlink/Web-Push

If you have composer setup in your project, it’s fairly simple to setup web-push using its package manager.

composer require minishlink/web-push

I initially started setting up a test page. I had gone through so much to get it working and go further, that I hadn’t posted much content to the blog. In addition, this was the first part of the application that needed to use the database, so configuring that was an interesting journey as well as it involved forking mysql-migrations and making tweaks to export procedures, functions, seed data and address a few bugs and features.

Instead of a sample PHP page showing how to use web-push, you’ll get to see a page processing a batch of notifications instead. It’s still a work in progress, but it’s at a functional point that works. The page is called by a cron job that would grab pending notifications from the database, send them one at a time, and update the database accordingly.

<?php
require_once '../common/Show.php';
require_once '../common/DatabaseHelper.php';
require_once '../common/HTTP_STATUS.php';
require __DIR__ . '/../vendor/autoload.php';

use Base64Url\Base64Url;
use Minishlink\WebPush\Subscription;
use Minishlink\WebPush\WebPush;

if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
    Show::error(
        'Method not allowed.',
        HTTP_STATUS_METHOD_NOT_ALLOWED
    );
    exit;
}

$appDomain = getenv('DOMAIN_APP');
$apiDomain = getenv('DOMAIN_API');
$email = getenv('WEB_PUSH_EMAIL');
$privateKey = getenv('VAPID_PRIVATE_KEY');
$publicKey = getenv('VAPID_PUBLIC_KEY');

$serverAuth = [
    'VAPID' => [
        'subject' => "mailto:$email",
        'publicKey' => $publicKey,
        'privateKey' => $privateKey,
    ],
];

$timeoutSeconds = 2;
$total = 0;
$failed = 0;
$succeeded = 0;
$errors = array();

$webPush = null;
$lastPushOptions = null;

$db = null;

try {
    $db = new DatabaseHelper();
    $rows = $db->selectRows("CALL sp_web_push_queue_get()");

    if ($rows === false) {
        Show::error(
            'Unable to get queue. Message: ' . $db->errorMessage . ' Error: ' . $db->error,
            HTTP_STATUS_INTERNAL_SERVER_ERROR
        );
        exit;
    } else if (count($rows) === 0) {
        Show::message(
            'Nothing to process.',
            HTTP_STATUS_OK
        );
        exit;
    } else {

        $total = count($rows);

        $db->prepareWithTypes("CALL sp_web_push_queue_complete(?, ?, ?)", "sis");

        foreach ($rows as $row) {

            $notificationId = $row['notification_id'];
            $subscriptionId = $row['subscription_id'];
            $title = $row['title'];
            $options = $row['options'];
            $ttl = $row['ttl'];
            $urgency = $row['urgency'];
            $topic = $row['topic'];
            $endpoint = $row['endpoint'];

            $p256dh = Base64Url::encode($row["p256dh"]);
            $auth = Base64Url::encode($row["auth"]);

            $pushOptions = [
                'TTL' => $ttl,
                'urgency' => $urgency,
                'batchSize' => 200,
            ];
            if (!empty($topic)) {
                $pushOptions['topic'] = $topic;
            }
            if ($lastPushOptions != $pushOptions) {
                $lastPushOptions = $pushOptions;
                $webPush = new WebPush($serverAuth, $pushOptions, $timeoutSeconds);
                $webPush->setAutomaticPadding(512);
            }

            $subscription = new Subscription(
                $endpoint,
                $p256dh,
                $auth
            );
            $payload = json_encode([
                'message' => $title,
                'options' => $options,
            ]);

            $status = 'failed';
            try {
                $report = $webPush->sendOneNotification(
                    $subscription,
                    $payload
                );

                if ($report->isSuccess()) {
                    $succeeded++;
                    $status = 'sent';
                } else if ($report->isSubscriptionExpired()) {
                    $failed++;
                    $status = 'bad-endpoint';
                    array_push($errors, 'Subscription Expired');
                } else {
                    $failed++;
                    $status = 'failed';
                    array_push($errors, $report->getResponse());
                }
            } catch (Exception $e) {
                $failed++;
                $status = 'failed';
                array_push($errors, $e);
            }
            $execute = $db->selectPreparedScalar(
                $subscriptionId,
                $notificationId,
                $status
            );
            if ($execute === false) {
                break;
            }
        }

        Show::data([
            'failed' => $failed,
            'succeeded' => $succeeded,
            'total' => $total,
            'errors' => $errors,
        ]);
    }

} catch (Exception $e) {
    Show::error($e->getMessage(), HTTP_STATUS_INTERNAL_SERVER_ERROR);
} finally {
    if ($db !== null) {
        $db->close();
    }
}

A few things are still being moved around and work with addressing security concerns and resource optimization. I was able to setup a cron job to run every 15 minutes (*/15 * * * *) to access the webpage

wget -O - -q https://dev-api.periplux.io/web-push/chron-notify

The stored procedure to get notifications in the queue has a transaction that creates a random batch id, assigns it to 100 queued notifications where the user hasn’t received any notifications in the past 4 hours – unless the notification is marked with a critical urgency, in which they get notified immediately regardless of how much time has passed since their last message. The transaction also marks notifications as expired if the specified TTL time in seconds has passed when the notification was first created.

CREATE PROCEDURE sp_web_push_queue_get()
BEGIN

    -- get the oldest 100 records that are currently queued
    -- unless the end-user has received a notification recently (or its critical)
    -- or the notification has expired
    -- mark those records as queued
    -- select joining notifications and subscriptions

    DECLARE v_batch_id CHAR(36);

    DECLARE CONTINUE HANDLER FOR SQLEXCEPTION, SQLWARNING
    BEGIN
        ROLLBACK;
    END;

    SET v_batch_id = UUID();

    START TRANSACTION;

    -- mark expired notifications
    UPDATE
      web_push_queue as wpq
    INNER JOIN web_push_notifications AS wpn ON 
      wpq.notification_id = wpn.id
    SET
      wpq.status = 'expired'
    WHERE
      wpq.status = 'queued'
      AND (
        DATE_ADD(
          wpn.created_at,
          INTERVAL wpn.ttl SECOND
        ) < NOW()
      );
      
    -- Flag notifications selected for batch
    UPDATE
        web_push_queue AS wpq
    INNER JOIN web_push_subscriptions as wps ON 
        wpq.subscription_id = wps.id
    INNER JOIN web_push_notifications AS wpn ON 
        wpq.notification_id = wpn.id
    SET
        wpq.batch_id = v_batch_id,
        wpq.status = 'processing'
    WHERE
        wpq.status = 'queued'
        AND wpq.batch_id IS NULL
        AND (
            wpn.urgency = 'critical'
            OR wps.last_sent_at IS NULL
            OR fn_web_push_next_dispatch(wps.last_sent_at) <= NOW()
        )
        AND DATE_ADD(wpn.created_at, INTERVAL wpn.ttl SECOND) > NOW()
    ORDER BY
        wpn.created_at ASC,
        wpq.created_at ASC
    LIMIT 100;

    -- Grab queued notifications
    SELECT
      wpq.notification_id,
      wpn.title,
      wpn.options,
      TIMESTAMPDIFF(
        SECOND,
        NOW(),
        DATE_ADD(wpn.created_at, INTERVAL wpn.ttl SECOND)
      ) as ttl,
      wpn.urgency,
      wpn.topic,
      wpq.subscription_id,
      wps.endpoint,
      wps.p256dh,
      wps.auth
    FROM 
        web_push_queue AS wpq
        INNER JOIN web_push_subscriptions as wps ON 
            wpq.subscription_id = wps.id
        INNER JOIN web_push_notifications AS wpn ON 
            wpq.notification_id = wpn.id
    WHERE
        wpq.batch_id = v_batch_id
    ORDER BY
        wpn.created_at ASC,
        wpq.created_at ASC;

    COMMIT;

END

At the end of a day, I have at most, 100 notifications being sent out every 15 minutes with a cooldown period for each push subscription. The function fn_web_push_next_dispatch is able to determine when the cooldown period ends for each subscription based on a configuration in the database.

CREATE FUNCTION fn_web_push_next_dispatch(
  p_last_sent_at TIMESTAMP
)
RETURNS TIMESTAMP DETERMINISTIC
BEGIN

  DECLARE v_dispatch_interval INT DEFAULT 240;

  IF p_last_sent_at IS NULL THEN
    SET p_last_sent_at = NOW();
  END IF;

  SELECT
    COALESCE(CAST(value AS UNSIGNED), v_dispatch_interval)
  INTO
    v_dispatch_interval
  FROM
    system_settings
  WHERE
    name = 'web-push-dispatch-interval';
  
  RETURN DATE_ADD(p_last_sent_at, INTERVAL v_dispatch_interval MINUTE);

END

This may get changed around so that it returns the minutes instead of calculating the users expiration. As it stands now, this function could be called 100 or more times when evaluating if a notification should be sent to a subscriber. If I just return the value itself, I can reduce the number of reads during the procedure to get the next batch of queued notifications.

Further changes

  • Associate a user account with a subscription
  • Associate
    • Language(s) – can limit notifications that the user can read
    • Country – can limit notifications/wording specific to country
    • Timezone with a subscription – can limit notifications to business hours instead of 2AM.
    • ip address – is this beneficial?
  • Ensure the same subscription isn’t selected for two separate notifications in the same batch
  • Allow the end-user to specify how frequent they receive notifications
  • Store VAPID in the database and assign subscriptions to the VAPID they signed up for
    • email/subject
    • domain/audience
    • expires_at
    • available_at
  • If more than one available, return the oldest VAPID public key available based on available_at/expires_at range
  • Notify all users when a VAPID is about to expire or being revoked, and delete the associated subscriptions
  • Store revoked_at/revoked_reason to retire a VAPID public key early along with why (Compromised, Legal, Domain/Subdomain Change, etc)
  • Return the VAPID public/private key to be used when getting subscription queues
  • Reduce the reads calling the user defined function for each user since the value from the settings table doesn’t change often.
  • Prevent unauthorized external resources calling the cron-notify web page from processing the queue.
  • Drop subscriptions if they haven’t been sent any notifications in a year or so, or never received a notification although they have been sent
  • Record if a notification was received by the web application
  • Determine if VAPID can be revoked on various push servers
  • Delete vs Soft Delete – should I keep subscriptions queues and notifications if they are no longer used?

More Details

If you want explicit details in how push notifications are constructed, I have some excellent resources for you. Some of these are outdated as the standard changed – but it’s a good starting point.

4 responses to “Web Push API”

  1. […] everything runs well, it’s a tool that sits in the background without a second thought. Using web push notifications, email, or some other medium, alerts can be pushed out to staff when the unexpected errors […]

  2. […] reason. I haven’t addressed it because I want a steady supply of errors, and its task to send web push notifications is not critical at this time. It was created before the secrets manager was setup, and the database […]

Discover more from Lewis Moten

Subscribe now to keep reading and get access to the full archive.

Continue reading