Here’s a bare-bones example for sending push notifications in Rails using Stimulus, the WebPush Gem, and VAPID keys. It doesn’t provide all the features you’re likely to need on a production app but it will teach you the basics.

How do VAPID web push notifications work in Rails?

  1. You generate and store VAPID keys: This stands for “Voluntary Application Server Identity”, it’s a method for sending and receiving push notifications without the need for a third part service such as Firebase. Your VAPID keys will be strings saved as environment variables.

  2. The user creates a subscription: This is handled via javascript using the subscribe method of the PushManager class, it returns a unique url plus some authentication related strings.

  3. Rails saves the subscription data: You need to store each user’s subscription data in your database for future reference. In this example I’ve used a somewhat standard looking controller with a simple create action.

  4. Rails hits the subscription endpoint: To send a push notification, your server hits the unique url of a subscription with a bunch of necessary data. I’m using the WebPush gem to handle this step.

  5. The push server hits the client: The relevant push server sends your message direct to the user’s service worker using the unique url, the service worker receives it and passes it to the operating system for generating the notification.

Step 1 - VAPID keys & WebPush Gem

Create yourself a new Rails project and add the WebPush Gem to the Gemfile.

# Gemfile
gem "web-push"

You can now open a Rails console and use the WebPush gem to generate your VAPID keys. You should only need to do this once unless your URL changes often (such as when hosting the site using ngrok).

rails c
vapid_key = Webpush.generate_key
vapid_key.public_key
vapid_key.private_key

Copy and paste the public and private keys and stick them in to your environment.

# config/environment.rb
ENV['VAPID_PUBLIC_KEY'] = "your-vapid-public-key-here"
ENV['VAPID_PRIVATE_KEY'] = "your-vapid-private-key-here"

Step 2 - Model & Controller

I’ve called the model PushSubscription and we only need 3 attributes for this proof of concept. For a fully fledged application you’ll likely want to reference the user id too.

rails g model push_subscription p256dh:string auth:string endpoint:string

Our controller only has the one method for creating subscriptions but eventually it will have another method for destroying subscriptions when a user decides to unsubscribe.

# config/routes.rb
Rails.application.routes.draw do
	resources :push_subscriptions, only: ['create']
end
# app/controllers/push_subscriptions_controller.rb

class PushSubscriptionsController < ApplicationController
	skip_forgery_protection # Don't use this in production!

	def create
		subscription = PushSubscription.find_by(auth: params[:auth])
		if !subscription
			subscription = PushSubscription.new(push_subscription_params)
			if subscription.save
				puts "PushSubscription created for #{subscription.auth}"
				head :no_content
			end
		end
	end

	def push_subscription_params
		params.require(:push_subscription).permit(
			:endpoint,
			:auth,
			:p256dh,
		)
	end
end

Step 3 - Service Worker

A service worker is necessary for push notifications to work so once you have it set up and running add the following block to it:

// public/sw.js

self.addEventListener("push", (event) => {
    const data = event.data?.json() ?? {};

    const title = data.title || "Default title";
    const body = data.body || "Default body";
    const tag = data.tag || "default-tag"
    const icon = data.icon || "/icon.png"

	event.waitUntil(
		self.registration.showNotification(title, { body, icon, tag })
	)
});

The tag property attribute allows for notifications to be grouped together so that they can be replaced instead of clogging up the user’s screen with similar notifications.

As for registering the service worker, I’m just hardcoding it into the application layout. I’m sure there are better ways to do this but for now it does the job.

# app/views/layouts/application.html.erb

<script type="text/javascript">
	if (navigator.serviceWorker) {
		navigator.serviceWorker.register('/sw.js')
			.then(function(reg) {
			console.log('Service worker change, registered the service worker');
		});
    }
    else {
        console.error('Service worker is not supported in this browser');
    }
</script>

Step 4 - View

Nothing fancy going on with this view except for the javascript under the h1 tags which provides a way of our stimulus controller to access the VAPID public key (stimulus controller details coming up). You could do this with a hidden text field too if you prefer.

# config/routes.rb
root "application#homepage"
# app/views/application/homepage.html.erb

<h1>Push Notifications</h1>

<script type="text/javascript">
	window.vapidPublicKey = new Uint8Array(<%= Base64.urlsafe_decode64(ENV['VAPID_PUBLIC_KEY']).bytes %>);
</script>

<div data-controller="push-subscriptions">
	<label>
		<input
			type="checkbox"
			data-action="push-subscriptions#handleSubscription"
			data-push-subscriptions-target="checkbox"
		>
		Enable notifications
	</label>

	<% PushSubscription.all.order(id: 'desc').each do | sub | %>
		<table style="margin-bottom: 2em">
			<tr><th>ID</th><td><%= sub.id %></td></tr>
			<tr><th>p256h</th><td>pd256: <%= sub.p256dh %></td></tr>
			<tr><th>auth</th><td><%= sub.auth %></td></tr>
			<tr><th>endpoint</th><td><%= sub.endpoint %></td></tr>
			<tr><th>created</th><td><%= sub.created_at %></td></tr>
		</table>
	<% end %>
</div>

Step 5 - Stimulus Controller

This Stimulus controller takes care of only the essential features; subscribing, unsubscribing, saving to the database, and pre-populating the checkbox.

// app/javascript/controllers/push_subscriptions_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
	static targets = [ "checkbox" ]

	connect() {
		this.handleCheckbox()
	}

	handleCheckbox() {
		navigator.serviceWorker.ready.then((reg) => {
			reg.pushManager.getSubscription().then((subscription) => {
				if (subscription) {
					this.checkboxTarget.checked = true
				} else {
					this.checkboxTarget.checked = false
				}
			});
		});
	}

	handleSubscription() {
		if (event.target.checked) {
			this.subscribe()
		} else {
			this.unsubscribe()
		}
	}

	unsubscribe() {
		navigator.serviceWorker.ready.then((reg) => {
			reg.pushManager.getSubscription().then((subscription) => {
			subscription
				.unsubscribe()
				.then((successful) => {
					console.log('unsubscribed from push notifications')
					this.handleCheckbox()
				})
				.catch((e) => {
					console.log('failed to unsubscribe from push notifications')
				});
			});
		});
	}

	subscribe() {
		navigator.serviceWorker.ready.then((reg) => {
			reg.pushManager
			.subscribe({
				userVisibleOnly: true,
				applicationServerKey: window.vapidPublicKey
			}).then((sub) => {
				//console.log({sub});
				//console.log(JSON.stringify(sub));
				this.save(sub)
				this.handleCheckbox()
			});
		});
	}

	save(sub) {
		const form = new FormData();
		form.append("[push_subscription]endpoint", sub.toJSON().endpoint)
		form.append("[push_subscription]auth", sub.toJSON().keys.auth)
		form.append("[push_subscription]p256dh", sub.toJSON().keys.p256dh)
		fetch('/push_subscriptions', {
			method: 'POST',
			body: form,
		})
	}
}

Step 6 - Sending Push Notifications

I’m using a rake task to send my push notifications but I imagine you’d want this to be in a Plain-old-Ruby-object or a module when used on a production site. Note that the task will send the notification to the most recent PushNotification created.

# lib/tasks/push_notification.rake

namespace :push_notification do
    desc "Send test push notification"
    task :send => :environment do

        sub = PushSubscription.last

        message = {
            title: "Oooh, baby, baby",
            body: "P-P-Push it real good!"
        }

        WebPush.payload_send(
            message: JSON.generate(message),
            endpoint: sub.endpoint,
            p256dh: sub.p256dh,
            auth: sub.auth,
            vapid: {
                subject: "mailto:[email protected]",
                public_key: ENV['VAPID_PUBLIC_KEY'],
                private_key: ENV['VAPID_PRIVATE_KEY']
            },
            ssl_timeout: 5, # value for Net::HTTP#ssl_timeout=, optional
            open_timeout: 5, # value for Net::HTTP#open_timeout=, optional
            read_timeout: 5 # value for Net::HTTP#read_timeout=, optional
        )
    end
end

Now let’s run the rake task to send your push notification:

rake push_notification:send