It is a Tuesday and a gym member named Dana upgrades from Basic to Plus at the front desk. The receptionist clicks save. Your database says Plus within a few milliseconds. Dana's pass, sitting in Apple Wallet on a phone in her pocket, still says Basic. Three days later she tries the Plus-only studio, the scanner reads her pass, and the door stays shut.
Nothing crashed. No error was logged. Two copies of the same fact disagreed, and nobody had written the code that makes them agree.
That is the real job of a pass integration after the first install. Issuing a pass is one request. Keeping it true for the next two years is a sync problem, and it has the same shape as any cache. Here is how to build it so the pass follows your database and not the other way around.
The pass is a cache, not a record
Start with a rule that saves you a lot of grief. Your database owns the truth. The pass is a rendered copy of it, stored on a device you do not control, shown to a person who may have no signal.
Once you say it that way, the design choices get easier. A cache needs three things:
- A key. Store the pass ID next to the row it mirrors. An
orderstable, amemberstable, aticketstable. One column,pass_id, filled when you issue. - An invalidation path. When the row changes, something has to tell the pass.
- A way to notice staleness. Caches drift. You need a signal when one does.
The first is trivial. The other two are the rest of this article. Apple's own guide makes the same split in its protocol. Your server tells the device that something changed, with a push that carries an empty payload, and the device then asks for the latest version. The push never carries the data. If you want the full protocol, we wrote it up in how Apple Wallet pass updates actually work. This post starts one level up, at the point where you decide not to run that machinery yourself.
Writing out: one PATCH per change
With a hosted API, invalidation is a single call. You send the new field values, the service re-signs the pass, and it pushes to every device that holds it. In the Node SDK it looks like this:
import { Passmint } from '@passmint/node'
const passmint = new Passmint({ apiKey: process.env.PASSMINT_API_KEY! })
export async function syncMemberPass(member: {
id: string
passId: string
tier: string
version: number
}) {
return passmint.passes.update(
member.passId,
{ fieldValues: { tier: member.tier } },
{ idempotencyKey: `member_${member.id}_v${member.version}` },
)
}Two details matter more than they look.
Send the whole current value, not a diff. Treat each call as the new truth for those fields. If the same member changes tier twice in a minute and the requests arrive out of order, the last write wins. That is a problem if "last" means "last to arrive" and not "last to happen". Read the row at send time, and send what is in it now.
Build the idempotency key from state, not from the clock. A random key per attempt defeats the point. A key like member_42_v7 means a retry of version 7 is safe, and version 8 is a new request. The API docs say replays within 24 hours return the cached response, and that reusing a key with a different body returns a 409. Treat that 409 as a bug report. It means your key does not describe your state.
The full parameter list lives in the API reference. A blog post should not restate it.
Where to call it from
This is where most teams get it wrong. The tempting spot is the request handler that saves the row. Do not call the wallet API there.
If the save succeeds and the wallet call times out, you have a changed row and a stale pass, and the user sees a spinner. If you wrap both in one transaction, a slow third party now holds your database open. If you call the wallet first, you can push a change that your transaction then rolls back.
The pattern that holds up is a small outbox. In the same transaction as the row change, insert a job row that says "sync pass for member 42". A worker picks it up, reads the current row, calls update, and marks the job done. If the call fails, the job stays and retries. Your save path stays fast, and the pass catches up on its own.
A few practical points:
- Coalesce. If ten jobs for the same pass are queued, run one. It reads current state anyway.
- Retry on 5xx and timeouts, not on 4xx. A
400will fail again. Voided passes, for example, cannot be updated and return a400. - Store the last version you synced. A
synced_versioncolumn besideversionmakes "which passes are behind" a plain SQL query. That query becomes your staleness signal.
Reading the result honestly
The update call does not just say "ok". It tells you where the change went. The response carries a delivery object for each wallet platform. On Apple, you get counts for installed devices, pushes Apple accepted, pushes that failed, and devices that were unregistered because the holder deleted the pass. On Google, you get a status such as updated or not_saved.
Read that object. Two cases look alike and are not:
not_savedon Google, or zero devices on Apple. Nobody holds this pass in that wallet. There is nothing to update and nothing wrong. The data is saved, and the pass will be right the moment someone adds it.- A failed count above zero. A platform refused the push. The change is still saved on the service side, and the device picks it up the next time it refreshes the pass. Passmint also retries automatically when the failure looks temporary, on a schedule that starts at 30 seconds and runs out after about 38 hours.
The second point is why "the call returned 200" is not the same as "the pass is current". Treat the 200 as "the change is recorded". Treat the delivery block and the webhooks below as "the change landed".
Reading in: webhooks for what happens next
Writing out covers your side of the conversation. The other half is hearing what the wallet world did. People add passes, delete them, and scan them at a door. Webhooks deliver those events to a URL you control, signed, so you can react without polling.
For a sync job, five event types do most of the work:
pass.added_to_wallet: the pass is now on a device. Flipinstalledon your row.pass.update_delivered: a device pulled the new version. This is the only event Apple confirms, so it is your proof of arrival.pass.update_failed: a platform refused an update or a void, and any automatic retries ran out. This is your alert.pass.removed: the holder deleted it. Stop spending effort keeping it fresh.pass.redeemed: a scanner accepted it. Mark attendance or apply the points.
Subscribing to everything is one wildcard. One exception to remember: template.republished is only sent to endpoints that ask for it by name, and the wildcard does not include it.
Here is a handler that verifies the signature and routes by type:
import express from 'express'
import { Passmint } from '@passmint/node'
const passmint = new Passmint({ apiKey: process.env.PASSMINT_API_KEY! })
const app = express()
app.post(
'/webhooks/passmint',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event
try {
event = passmint.webhooks.constructEvent(
req.body,
req.headers['passmint-signature'] as string,
process.env.PASSMINT_WEBHOOK_SECRET!,
)
} catch {
return res.sendStatus(400)
}
await recordEvent(event.id, event.type, event.data.object.pass?.id)
res.sendStatus(200)
},
)The handler does almost nothing on purpose. It verifies, stores the event, and answers. The docs ask for a 2xx within 10 seconds, and anything else counts as a failure. Do the real work from the stored row, in a worker, not in the request.
Note express.raw. Signature verification needs the exact bytes that were sent. If a JSON body parser runs first and re-serializes, the HMAC will not match and every delivery will fail. This is the single most common reason a first webhook endpoint never works.
Retries mean duplicates
Webhooks are at-least-once. A delivery that fails is retried up to seven times, backing off from 30 seconds to 24 hours. Your endpoint will see the same event twice, sometimes after a deploy, sometimes after your own 500.
Two defenses, and you want both:
- Dedupe on the event
id. Make it the primary key of your events table. A second insert fails, and you return 200 without doing the work again. - Make the effect idempotent anyway. "Set
installed = true" can run ten times. "Add one point" cannot. Prefer writes that describe a state over writes that describe a change.
Order is not guaranteed either. A pass.removed can arrive before the pass.added_to_wallet it follows if the first delivery was retried. Compare the event's created_at with what you already hold, and ignore anything older than your last write for that pass.
If your endpoint was down for a stretch, the API reference lists a way to replay deliveries, so a bad afternoon does not mean lost events. Pair that with a nightly job that looks for rows where version is ahead of synced_version. Webhooks are the fast path, and the query is the safety net.
A worked example, with invented numbers
Consider a representative multi-site gym chain, based on an anonymized and simplified example. About 40,000 members, three tiers, and a pass per member.
Before the sync work, tier changes were rare enough that front desk staff just reissued the pass by hand. A few hundred a month, often around 3 percent of members, ended up with a pass that disagreed with the system. Each one meant a support ticket and a minute of awkward conversation at a door.
The rebuild was small:
- Add
pass_id,versionandsynced_versionto the members table. - On any change to tier, status or expiry, bump
versionand write an outbox job in the same transaction. - A worker calls
passes.updatewith the current row and the keymember_<id>_v<version>. On success, it setssynced_version. - A webhook endpoint stores events. A
pass.update_failedevent pages the on-call channel once a day in a summary, not per event. - A nightly query lists members where
synced_version < versionfor more than an hour.
The numbers here are made up, so do not quote them. The shape is the useful part. The mismatch stops being a mystery and becomes a number on a dashboard, and that number can sit at zero.
What not to sync
Not every change deserves a push. Apple's guide is blunt about this. A field can carry a change message, and when the value changes the device shows that message as an interruption. Apple says to keep those for time-sensitive news. A delayed concert qualifies. A new phone number for customer service does not.
Apply the same filter to your own pipeline:
- Sync what the holder reads. Tier, balance, seat, gate, expiry.
- Skip what only you read. Internal notes, marketing segments, anything that belongs in
metadataand never on the face of the pass. - Batch the noisy ones. A points balance that changes on every purchase can update on a short delay and still feel live.
Every update is a re-sign and a push. Fewer, meaningful updates keep the pass feeling alive. Constant ones teach people to switch notifications off.
You do not need a clever architecture. You need three habits: write from current state with a state-based idempotency key, record webhook events before you act on them, and keep one query that proves the two copies agree.
Do those, and Dana's upgrade shows up on her lock screen before she reaches the studio door. If you want to try it, Passmint gives you test-mode keys and a webhook endpoint you can point at a local tunnel, so you can rehearse the whole loop before a real member is involved.
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 →