All articles
// developers

Apple Wallet Passes in Rails: Issue, Serve and Update Them Without Building a Signing Pipeline

The Rails gems stop at generating a .pkpass. Here is the model, job, controller and webhook that cover the rest, using nothing but the Ruby standard library.

Updated
Apple Wallet Passes in Rails: Issue, Serve and Update Them Without Building a Signing Pipeline
Julio Song
10 min read

A Rails developer we'll call Priya ships a loyalty feature on a Thursday. Members sign up, and a "Add to Apple Wallet" button appears on the confirmation page. By Friday afternoon the support inbox has two messages. One member added the card, then changed their name, and the card still shows the old spelling. Another tapped the button on her phone and Safari offered to download a file called download.

Priya's stack is fine. The gem she picked generates a valid .pkpass, and the signing works. What the gem doesn't do is serve the file with the header Wallet wants, or reach into a phone that already holds the pass. Those two gaps are where most Rails integrations spend their second week.

This post builds the whole path in Rails: a model, a background job, a download route and a webhook receiver. You'll see where the existing gems end, what a hosted API takes off your plate, and the handful of Rails details that bite.

What the Rails gems actually give you

The Ruby ecosystem has three names worth knowing. passbook is the oldest, and its RubyGems page shows version 0.4.4 from February 2017, so it predates every certificate change Apple has made since. passbook2 is a fork that patched it for modern Ruby and OpenSSL. passkit is the one most Rails teams land on.

passkit is a Rails engine, and it goes further than the others. Its GitHub README describes endpoints to create, register, update and unregister passes, ActiveRecord models for devices and logs, and a small admin dashboard. RubyGems shows it sat at 0.7.0 from April 2024 until a run of 0.8 releases that began on September 11, 2026, which is a good sign for a project that had gone quiet.

It still leaves you holding the pieces that hurt. You supply your own .p12 certificate, the Apple intermediate certificate, your team identifier and your Pass Type ID. The README lists push notifications as not yet provided, so the step that makes a pass update on a phone is yours to write. It also says Google Wallet isn't covered, and points Android users at a third-party app that reads .pkpass files.

That's a reasonable trade for a team that wants to own every moving part. It's a heavy one for a team that wants a loyalty card on two platforms by the end of the sprint.

The shape of the integration

Skip the engine and treat pass issuing as what it is: a call to an outside service, triggered by something that happened in your database. The model records the pass ID it gets back. A job makes the call. A controller sends the member to a download link. A webhook tells you what happened afterward.

Passmint's REST API takes a template ID and field values and returns a signed pass. In test mode it signs with Passmint's own certificate, so you can run this entire post's code without an Apple Developer account. The quickstart covers creating a key and a template.

Start with a migration and the credentials:

bin/rails generate migration AddPassmintToMembers passmint_pass_id:string passmint_pass_url:string
bin/rails db:migrate
bin/rails credentials:edit

Put passmint: { api_key: pmk_test_..., webhook_secret: ..., template_id: tmpl_... } in the encrypted credentials file. Don't read the key from a constant checked into git, and don't put it in config/application.rb.

A client in about fifty lines of standard library

Passmint documents a Node SDK and a plain REST API, and for Ruby the REST API is enough. Net::HTTP and JSON ship with Ruby, which keeps your Gemfile unchanged and your supply chain short.

# app/services/passmint_client.rb
require "net/http"
require "json"

class PassmintClient
  BASE = URI("https://api.passmint.com")

  class Error < StandardError
    attr_reader :status

    def initialize(status, message)
      @status = status
      super(message)
    end
  end

  def create_pass(template_id:, email:, name:, fields:, idempotency_key:)
    send_json(Net::HTTP::Post, "/v1/passes", idempotency_key, {
      template_id: template_id,
      holder_email: email,
      holder_name: name,
      field_values: fields
    })
  end

  def update_pass(pass_id, fields:, idempotency_key:)
    send_json(Net::HTTP::Patch, "/v1/passes/#{pass_id}", idempotency_key, {
      field_values: fields
    })
  end

  def download_link(pass_id, expires_in: 3600)
    send_json(Net::HTTP::Post, "/v1/passes/#{pass_id}/download-links", nil, {
      expires_in: expires_in
    })
  end

  private

  def send_json(verb, path, idempotency_key, body)
    request = verb.new(BASE + path)
    request["Authorization"] = "Bearer #{Rails.application.credentials.dig(:passmint, :api_key)}"
    request["Content-Type"] = "application/json"
    request["Idempotency-Key"] = idempotency_key if idempotency_key
    request.body = body.to_json

    response = Net::HTTP.start(BASE.host, BASE.port, use_ssl: true, read_timeout: 10) do |http|
      http.request(request)
    end

    parsed = JSON.parse(response.body)
    raise Error.new(response.code.to_i, parsed.dig("error", "message")) unless response.is_a?(Net::HTTPSuccess)

    parsed
  end
end

Two details earn their keep. The Idempotency-Key header lets you retry a POST or PATCH safely, and the API replays the cached response for 24 hours instead of creating a duplicate. And the error envelope always has the same shape, so one dig gives you a message worth logging.

Issue the pass from a job, not a request

The tempting place for this call is the controller that creates the member. Don't. A network call to a third party inside a request means a slow signup page when the API is slow, and a failed signup when it's down.

Use after_create_commit to enqueue a job once the row is safely in the database:

# app/models/member.rb
class Member < ApplicationRecord
  after_create_commit { IssueWalletPassJob.perform_later(id) }
  after_update_commit :push_pass_update, if: :saved_change_to_name?

  private

  def push_pass_update
    UpdateWalletPassJob.perform_later(id) if passmint_pass_id.present?
  end
end

# app/jobs/issue_wallet_pass_job.rb
class IssueWalletPassJob < ApplicationJob
  retry_on PassmintClient::Error, wait: :polynomially_longer, attempts: 5

  def perform(member_id)
    member = Member.find(member_id)
    return if member.passmint_pass_id.present?

    pass = PassmintClient.new.create_pass(
      template_id: Rails.application.credentials.dig(:passmint, :template_id),
      email: member.email,
      name: member.name,
      fields: { memberNumber: member.number, points: member.points.to_s },
      idempotency_key: "member-#{member.id}-issue"
    )

    member.update_columns(passmint_pass_id: pass["id"], passmint_pass_url: pass["url"])
  end
end

The first line of perform guards against a re-run, and the idempotency key guards against the case the guard can't see: the API call succeeded, your process died before update_columns, and the retry would otherwise mint a second pass. Using update_columns keeps the write from firing your own callbacks.

Field values are strings. Passing member.points as an integer is the kind of mistake that works in a console and fails in a job, so call to_s at the boundary.

The download route, and who is allowed to hit it

Here is the controller behind the button:

# app/controllers/wallet_passes_controller.rb
class WalletPassesController < ApplicationController
  before_action :authenticate_user!

  def show
    member = current_user.member
    return head :not_found if member.passmint_pass_id.blank?

    link = PassmintClient.new.download_link(member.passmint_pass_id)
    redirect_to link["url"], allow_other_host: true
  end
end

Note what the route doesn't take: no :id in the path. It reads the member from current_user. A signed pass carries a barcode, and a route like /members/:id/pass that trusts the number in the URL hands anyone with sequential IDs everyone else's loyalty card. Scoping through the signed-in user removes the whole class of bug.

The allow_other_host: true is not optional. Rails 7 raises UnsafeRedirectError on a redirect to another host unless you opt in, and the download link lives on Passmint's domain. Because you minted that link yourself, for a member you already authenticated, the opt-in is safe.

The expiring link matters for a second reason. A permanent pass URL pasted into an email can be forwarded. A link that dies in an hour can't be reused by whoever finds it later, though the API docs are clear that expiry protects the link and not a .pkpass already on a phone.

If you sign passes yourself instead, this is where the header trap lives. Return the bytes with send_data pkpass, type: "application/vnd.apple.pkpass", disposition: "attachment", filename: "pass.pkpass". Anything near it, application/octet-stream included, and Safari downloads a file called download and never offers Wallet. That's what Priya's second member saw.

Updates: what changes on the phone

Apple's update model is a small protocol. At install, the phone registers a device and push token with a web service URL baked into the pass. When a field changes, your server sends an empty APNs push to every registered token. Wallet then calls back, asks which passes changed and fetches each one again, re-signed. The Apple Wallet documentation describes the full sequence.

Building that in Rails means five routes, a devices table, an APNs client with its own credentials and pruning for tokens that die when a holder deletes a pass. With the API, the update is a PATCH:

# app/jobs/update_wallet_pass_job.rb
class UpdateWalletPassJob < ApplicationJob
  retry_on PassmintClient::Error, wait: :polynomially_longer, attempts: 5

  def perform(member_id)
    member = Member.find(member_id)

    PassmintClient.new.update_pass(
      member.passmint_pass_id,
      fields: { memberNumber: member.number, points: member.points.to_s },
      idempotency_key: "member-#{member.id}-#{member.updated_at.to_i}"
    )
  end
end

The response includes a delivery object that reports how many Apple devices the update reached and whether Google Wallet accepted it. A failed delivery doesn't fail the request. The change is saved, and the phone picks it up the next time it refreshes the pass.

Build the idempotency key from the version of the record, as above, rather than from the member alone. A key that never changes would make every update after the first replay the cached response and quietly do nothing.

Hearing back: a webhook receiver

The last piece is knowing what happened. Passmint sends signed webhooks for events such as pass.added_to_wallet and pass.removed. The webhook docs list them all. This controller verifies the signature before it trusts anything:

# app/controllers/passmint_webhooks_controller.rb
class PassmintWebhooksController < ApplicationController
  skip_forgery_protection

  TOLERANCE = 5.minutes

  def create
    payload = request.raw_post
    return head :bad_request unless valid_signature?(payload, request.headers["passmint-signature"])

    event = JSON.parse(payload)
    pass_id = event.dig("data", "object", "pass", "id")

    case event["type"]
    when "pass.added_to_wallet"
      Member.where(passmint_pass_id: pass_id).update_all(wallet_added_at: Time.current)
    when "pass.removed"
      Member.where(passmint_pass_id: pass_id).update_all(wallet_added_at: nil)
    end

    head :ok
  end

  private

  def valid_signature?(payload, header)
    parts = header.to_s.split(",").to_h { |pair| pair.split("=", 2) }
    timestamp, signature = parts["t"], parts["v1"]
    return false if timestamp.blank? || signature.blank?
    return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE

    secret = Rails.application.credentials.dig(:passmint, :webhook_secret)
    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")

    ActiveSupport::SecurityUtils.secure_compare(expected, signature)
  end
end

Three Rails details matter here. skip_forgery_protection is required because a webhook has no CSRF token. request.raw_post returns the exact bytes that were signed, and re-serializing parsed params would change whitespace and break the HMAC. And secure_compare takes constant time, which a plain == does not.

Add wallet_added_at:datetime to your members table and post "/webhooks/passmint", to: "passmint_webhooks#create" to your routes. Deliveries retry up to seven times on failure, so make the handler safe to run twice. An update_all that sets a timestamp already is.

What this costs you, and what it doesn't

Count what you wrote: one 50-line client, two jobs, a thin controller and a webhook receiver. You didn't touch OpenSSL, you didn't register a device table and you don't hold a push credential. A Pass Type ID certificate expires on a schedule nobody puts on a calendar, and when you sign yourself, the day it lapses is the day every new pass fails to install. Behind the API, that renewal is Passmint's job rather than yours.

The tradeoff is a dependency. Your signup flow now rests on an external API, which is why the job retries and why the member never waits on the call. If owning the signing pipeline is a requirement, passkit is the right starting point and this post's controller advice still applies.

The same model also covers the other wallet. Google Wallet works from signed JWTs, as Google's documentation explains, with a different credential and a different update path. Through the API both are the same call, so the code above issues an Android pass too, with no second implementation.

If you're on Rails and want to see this working, the quickstart takes about five minutes to a pass on a real phone, and every snippet above runs against a test key.

Primary sources

Common questions

Yes. The passkit gem is a Rails engine that generates and serves .pkpass files, with ActiveRecord models for registered devices. Its README says push notifications are not provided yet and that Google Wallet is not covered. Older gems such as passbook have not shipped a release since 2017.
Return the bytes with the content type application/vnd.apple.pkpass, using send_data with an attachment disposition. Safari only hands the file to Wallet when the content type is exact. If an API signs the pass for you, redirect to its download link instead and skip serving bytes yourself.
Apple requires a web service that registers devices, plus an APNs push to wake each phone, after which Wallet fetches the re-signed pass. In Rails you can build all of that yourself, or call a PATCH endpoint on a pass API from an ActiveJob and let the API handle registration and push delivery.
No. Issue it from an ActiveJob after the record commits, and send an idempotency key so a retried job cannot create a second pass for the same member.
Julio Song

Technical content writer, Passmint

Julio is a technical content writer at Passmint. He writes about Apple PassKit, the Google Wallet API, and what breaks when wallet passes meet production traffic.

More from Julio Song →

Related reading

Share this article