---
title: "Push to Subscribe"
description: "Integrating Web Push with Rails"
---

# Push to Subscribe

![A bird pushing a red button on a phone](https://fly.io/ruby-dispatch/push-to-subscribe/assets/push-to-subscribe-cover.webp)

[Annie Ruygt](https://annieruygtillustration.com/)

We’re [Fly.io](https://fly.io/). We run apps for our users on hardware we host around the world. Fly.io happens to be a great place to run Rails applications. Check out how to [get started](https://fly.io/speedrun/)!

All your favorite social apps have the ability to send you notifications even when you aren’t even using them. Sending notifications used to require different mechanisms for different clients, but with [Safari](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/?) joining the party, now is a good time to consider implementing the [Push API](https://caniuse.com/push-api) in your applications. This can be a great way to engage with your audience and clients.

Unfortunately the [webpush protocol](https://datatracker.ietf.org/doc/html/draft-ietf-webpush-protocol-12), [explanations](https://web.dev/push-notifications-web-push-protocol/), [API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API), and even [gem](https://github.com/pushpad/web-push) share a number of problems. Overall, they:

- provide too many choices
- are incomplete
- may even suggest things that no longer work

This blog post will take you through creating a complete Rails 7 application with Web Push, and will do so in a way that will show you how to add Web Push to your existing Rails application.

This demo application’s model will include Users, Subscriptions, and Notifications, where a User may have both many Subscriptions and many Notifications. We are going to make use of the [web-push](https://github.com/pushpad/web-push) gem and create a [service worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API).

Before proceeding to the logic, let’s get some scaffolding/administrivia out of the way. Start by running the following commands:

```
rails new webpush --css=tailwind
cd webpush
bin/rails generate scaffold User name:string
bin/rails generate scaffold Subscription \
  endpoint auth_key p256dh_key user:references
bin/rails generate scaffold Notification \
  user:references title:string body:text
bin/rails db:migrate
bundle add web-push
bin/rails generate controller ServiceWorker
```

Next run the following in the rails console to add some [VAPID](https://datatracker.ietf.org/doc/html/rfc8292) keys to your credentials:

```
creds = Rails.application.credentials
key = Webpush.generate_key
add = YAML.dump('webpush' => key.to_h.stringify_keys).sub('---','')
creds.write creds.read + "\n# Webpush VAPID keys#{add}"
```

Then add some routes to `config/routes.rb`:

```
get "/service-worker.js", to: "service_worker#service_worker"
post "/notifications/change", to: "notification#change",
  as: "change_notifications"
```

Now let’s get started.

## Add a Service Worker

Web push notifications require you to install some JavaScript that runs separate from your web application as a *service worker* . This code has two responsibilities: listen for push requests and show them as notifications, and post subscription change information to the server. Place the following into `app/views/service_worker/service_worker.js.erb` to accomplish both tasks:

```
self.addEventListener("push", event => {
  const { title, ...options } = event.data.json();
  self.registration.showNotification(title, options);
})

self.addEventListener("pushsubscriptionchange", event => {
  const newSubscription = event.newSubscription?.toJSON()

  event.waitUntil(
    fetch(<%= change_notifications_path.inspect.html_safe %>, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        old_endpoint: event.oldSubscription?.endpoint,
        new_endpoint: event.newSubscription?.endpoint,
        new_p256dh: newSubscription?.keys?.p256dh,
        new_auth: newSubscription?.keys?.auth
      })
    })
  )
})
```

Update `app/controllers/service_worker_controller.rb` to disable authentication for this script:

```
class ServiceWorkerController < ApplicationController
  skip_before_action :verify_authenticity_token

  def service_worker
  end
end
```

There are no secrets in this file, so this is safe to do.

## Sending notifications on save

For this demo, notifications are stored in the database, and are sent when saved. We accomplish this by using the `after_save` [Active Record Callback](https://guides.rubyonrails.org/active_record_callbacks.html#available-callbacks) in `app/models/notification.rb`:

```
class Notification < ApplicationRecord
  belongs_to :user

  after_save do |notification|
    notification.user.push(notification)
  end
end
```

Next we update the `User` model in `app/models/user.rb` to iterate over the subscriptions and call `WebPush.payload_send` on each. While in this file, we also add `has_many` calls to complete the relations.

```
class User < ApplicationRecord
  has_many :notifications
  has_many :subscriptions

  def push(notification)
    creds = Rails.application.credentials

    subscriptions.each do |subscription|
      begin
        response = WebPush.payload_send(
          message: notification.to_json,
          endpoint: subscription.endpoint,
          p256dh: subscription.p256dh_key,
          auth: subscription.auth_key,
          vapid: {
            private_key: creds.webpush.private_key,
            public_key: creds.webpush.public_key
          }
        )

        logger.info "WebPush: #{response.inspect}"

      rescue WebPush::ExpiredSubscription,
             WebPush::InvalidSubscription
        logger.warn "WebPush: #{response.inspect}"

      rescue WebPush::ResponseError => response
        logger.error "WebPush: #{response.inspect}"
      end
    end
  end
end
```

While this is a fair number of lines of code, it is pretty straightforward, merely passing the notification, the subscription, and VAPID keys we generated earlier to the `payload_send` call, and the results from this call are logged.

Expired and invalid subscriptions should eventually be cleaned up, but perhaps not immediately as they may be in the process of being changed. As they say, this is left as an exercise for the student.

## User Interface

We are going to make one functional and one cosmetic change to the user interface for this demo.

Since users have subscriptions, we add a Create Subscription button to the users page in `app/views/users/_user.html.erb`:

```
<span data-controller="subscribe" class="hidden"
  data-path=<%= subscriptions_path %> data-key="<%=
    Rails.application.credentials.webpush.public_key.tr("_-", "/+")
  %>">
  <%= render partial: 'subscriptions/form', locals: {
     subscription: Subscription.new(user: user)
   } %>
</span>
```

This `span` element makes use of a Stimulus controller that we will get to in a minute, is initially hidden, contains the `subscriptions_path` and `public_key` as data attributes, and renders the subscriptions form with the user pre-filled in.

Where you place this HTML fragment in the form is up to you. If it is inside the `if` statement, this will only show up on the index page.

The cosmetic change is actually more involved. We start by getting a list of users in NotificationsController in `app/controllers/notifications_controller.rb`:

```
before_action only: %i[ new edit ] do
  @users = User.all.map {|user| [user.name, user.id]}
end
```

Next we make use of this list in `app/views/notifications/_form.html.erb`:

```
<%= form.select :user_id, @users, {}, class: "..." %>
```

And we also make one tiny change to `app/views/notifications/_notification.html.erb`:

```
<%= notification.user.name %>
```

Taken together, this lets us create, view, and edit notifications using user names instead of record ids.

If you feel so inclined, you can make the same change to the subscriptions pages, but as that information is lower level and not something that you will be directly editing, it is fine to leave it as it is for now.

## Wiring up the Browser

At this point, we have a hidden form on user pages. There is a lot left to be done:

- We have to hide the individual form fields, and then unhide the rest of the form to reveal the Create Subscription button.
- We disable this button if notifications have already been granted or denied on this device.
- When the button is clicked we need to request permission for notifications, and if granted do the following in sequence:
  - Register the service worker
  - Create subscription
  - Post the subscription endpoint and keys to the server

The code for all of this is below. Place it into `app/javascript/controllers/subscribe_controller.js`:

```
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="subscribe"
export default class extends Controller {
  connect() {
    // hide notification form fields
    for (const field of this.element.querySelectorAll('.my-5')) {
      field.style.display = 'none'
    }

    // unhide remainder of the form, revealing the submit button
    this.element.style.display = 'inline-block'

    // find submit button
    const submit = this.element.querySelector('input[type=submit]')

    if (!navigator.serviceWorker || !window.PushManager) {
      // notifications not supported by this browser
      this.disable(submit)
    } else if (Notification.permission !== "default") {
      // permission has already been granted or denied
      this.disable(submit)
    } else {
      // prompt for permission when clicked
      submit.addEventListener("click", event => {
        event.stopPropagation()
        event.preventDefault()
        this.disable(submit)

        // extract key and path from this element's attributes
        const key = Uint8Array.from(atob(this.element.dataset.key),
          m => m.codePointAt(0))
        const path = this.element.dataset.path

        // request permission, perform subscribe, and post to server
        Notification.requestPermission().then(permission => {
          if (Notification.permission === "granted") {
            navigator.serviceWorker.register('/service-worker.js')
            .then(registration => {
              return registration.pushManager.subscribe({
                userVisibleOnly: true,
                applicationServerKey: key
              })
            })
            .then(subscription => {
              subscription = subscription.toJSON()
              let formData = new
                FormData(this.element.querySelector('form'))
              formData.set('subscription[endpoint]',
                subscription.endpoint)
              formData.set('subscription[auth_key]',
                subscription.keys.auth)
              formData.set('subscription[p256dh_key]',
                subscription.keys.p256dh)

              return fetch(path, {
                method: 'POST',
                headers: {'Content-Type':
                  'application/x-www-form-urlencoded'},
                body: new URLSearchParams(formData).toString()
              })
            })
            .catch(error => {
              console.error(`Web Push subscription failed: ${error}`)
            })
          }
        })
      })
    }
  }

  // disable the submit button
  disable(submit) {
    submit.removeAttribute('href')
    submit.style.cursor = 'not-allowed'
    submit.style.opacity = '30%'
  }
}
```

While the largest block of code in this entire demo, it isn’t particularly complex: it merely performs a few checks, runs steps in sequence, and extracts and passes in data as required.

## Updating Subscriptions

One final piece completes the puzzle. Our application needs to update subscription information as it changes. These requests will be made by the service worker.

Add the following to `app/controllers/subscriptions_controller.rb`:

```
# POST /subscriptions/change
def change
  subscription = Subscription.find_by_endpoint!(
    params[:old_endpoint]
  )

  if params[:new_endpoint]
    subscription.endpoint = params[:new_endpoint]
  end

  if params[:new_p256dh]
    subscription.p256dh_key = params[:new_p256dh]
  end

  if params[:new_auth]
    subscription.auth_key = params[:new_auth]
  end

  if @subscription.save
    format.json {
      render :show,
      status: :ok,
      location: subscription
    }
  else
    format.json {
      render json: subscription.errors,
      status: :unprocessable_entity
     }
  end
end
```

For this to work, you will also need to add to the start of the controller:

```
skip_before_action :verify_authenticity_token, only: [:change]
```

Endpoints generally are secrets, but it is beyond my expertise to determine how this can be compromised and exploited.

## Try it out!

At this point you should have a working demo. You can launch your server by running `bin/dev`, create a user by going to [`http://localhost:3000/users`](http://localhost:3000/users). Create a subscription by clicking on the Create Subscription button associated with that user, and finally create a notification by going to [`http://localhost:3000/notifications`](http://localhost:3000/notifications).

[`http://localhost:3000/subscriptions`](http://localhost:3000/subscriptions) can also be used to see the messy details.

## You can play with this right now.

It’ll take less than 10 minutes to get your Rails application running globally.

[Try Fly for free →](https://docs.fly.io/rails)

## Explore Further

This blog post can also be viewed as a series of steps that you can use to add push notification to existing Rails applications. Undoubtedly your UI and model will be different, and those changes may affect the stimulus controller, but the basic steps to add the functionality should be the same and hopefully much of this code can be reused.

This demo also only sends `title` and `body` for notifications, there are [more options](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification#parameters) that you can play with.

If you want to explore more, two of the best resources I used in preparing this post were:

- [pushpad’s service worker code](https://pushpad.xyz/service-worker.js)
- [Web push with Ruby on Rails](https://www.youtube.com/watch?v=d0XJ9cLfoTs)
