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:editPut 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
endTwo 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
endThe 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
endNote 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
endThe 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
endThree 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
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 →