How you store and queryPushSubscription objects depends on your server-side language and database, but the pattern is consistent. In the demo, the page sends the subscription to the backend with a simple POST request:
function sendSubscriptionToBackEnd(subscription) {
return fetch('/api/save-subscription/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
})
.then(function (response) {
if (!response.ok) {
throw new Error('Bad status code from server.');
}
return response.json();
})
.then(function (responseData) {
if (!(responseData.data && responseData.data.success)) {
throw new Error('Bad response from server.');
}
});
}
The Express server in the demo has a corresponding request listener for the /api/save-subscription/ endpoint:
app.post('/api/save-subscription/', function (req, res) {
The route first validates the subscription to reject malformed requests:
const isValidSaveRequest = (req, res) => {
// Check the request body has at least an endpoint.
if (!req.body || !req.body.endpoint) {
// Not a valid subscription.
res.status(400);
res.setHeader('Content-Type', 'application/json');
res.send(
JSON.stringify({
error: {
id: 'no-endpoint',
message: 'Subscription must have an endpoint.',
},
}),
);
return false;
}
return true;
};
Once validated, it stores the subscription and returns an appropriate JSON response:
return saveSubscriptionToDatabase(req.body)
.then(function (subscriptionId) {
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify({data: {success: true}}));
})
.catch(function (err) {
res.status(500);
res.setHeader('Content-Type', 'application/json');
res.send(
JSON.stringify({
error: {
id: 'unable-to-save-subscription',
message:
'The subscription was received but we were unable to save it to our database.',
},
}),
);
});
The demo uses nedb, a file-based database that requires zero setup, but any database works for production use:
function saveSubscriptionToDatabase(subscription) {
return new Promise(function (resolve, reject) {
db.insert(subscription, function (err, newDoc) {
if (err) {
reject(err);
return;
}
resolve(newDoc._id);
});
});
}
Saving subscriptions: the server side
The next step is an event that triggers sending messages to users. A typical approach is an admin page that lets you compose and fire the push message, but a local script or anything with access to the stored PushSubscription objects works. The demo includes a public "admin-like" page for this purpose.
The application server key mentioned when subscribing a user has a matching private key on the backend. In the demo, those values are loaded into the Node app directly:
The mailto: string is required and must be a URL or an email address. It is sent to the push service as part of the triggering request, giving the service a way to contact the sender if needed.
The web-push module is now ready. The demo's admin panel triggers the actual push:
Clicking the button sends a POST request to /api/trigger-push-msg/, which is the signal for the backend to send push messages:
app.post('/api/trigger-push-msg/', function (req, res) {
Sending and handling errors
When that request arrives, the backend pulls all subscriptions from the database and calls the push function for each one:
return getSubscriptionsFromDatabase().then(function (subscriptions) {
let promiseChain = Promise.resolve();
for (let i = 0; i < subscriptions.length; i++) {
const subscription = subscriptions[i];
promiseChain = promiseChain.then(() => {
return triggerPushMsg(subscription, dataToSend);
});
}
return promiseChain;
});
The triggerPushMsg() function uses the web-push library to deliver the message:
const triggerPushMsg = function (subscription, dataToSend) {
return webpush.sendNotification(subscription, dataToSend).catch((err) => {
if (err.statusCode === 404 || err.statusCode === 410) {
console.log('Subscription has expired or is no longer valid: ', err);
return deleteSubscriptionFromDatabase(subscription._id);
} else {
throw err;
}
});
};
webpush.sendNotification() returns a promise. A resolved promise means success; a rejection requires checking the error to determine whether the subscription is still valid. The most useful signal is the HTTP status code from the push service, since error message content varies. This example checks for 404 (Not Found) and 410 (Gone), which indicate the subscription has expired or is no longer valid, and removes them from the database. Other errors are propagated by calling throw err, which rejects the promise returned by triggerPushMsg().
After iterating through the subscriptions, the route returns a JSON response:
.then(() => {
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify({ data: { success: true } }));
})
.catch(function(err) {
res.status(500);
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify({
error: {
id: 'unable-to-send-messages',
message: `We were unable to send messages to all subscriptions : ` +
`'${err.message}'`
}
}));
});
The complete implementation loop is:
Define an API to receive subscriptions from the web page and store them in a database.
Define an API that triggers sending push messages, such as one called from an admin panel.
Retrieve the stored subscriptions and send each one a message using a web-push library.
The steps hold regardless of the backend language, since the mechanics of VAPID, encryption, and push service requests are identical. What these libraries do internally is to handle the protocol details that make triggering a push message otherwise difficult to get right and diagnose.
About a year ago, I was offered a presentation slot at the WeAreDevelopers World Congress in Berlin. I rarely take speaking engagements, especially international ones, but this one arrived at just the right time, the right place, and with the right person – I said yes, on the contingency that Ben Dumke-von der Ehe joins me in the presentation. Ben is an early community hire at Stack Overflow who l