Skip to main content

Troubleshooting: wallet pass generation counter and void conflict

Three symptoms send operators here. A holder’s phone shows stale pass content after your update succeeded. An update or void returns 409 CONFLICT. Or a deep-link that “top up points” flow minted yesterday opens the wrong pass. Each maps to one of the lifecycle edges — active, voided, expired — the wallet pass lifecycle defines; this page names the check for each edge and the fix at the level you own. For the issuance workflow itself and the field reference, start at the Wallet Passes channel guide.
The wallet-pass operations you run — issue, update, void — are tenant-owned controls that move through your own ledger. A 409 or a drifted generation is the platform honoring the transition you already made, not a platform fault. The fixes below are yours to run; escalations go to the ledger read-path, not the wallet.

Cause table

Read the pass first with GET /wallet-passes/:id, or list with the filter the channel guide documents (?type=…&status=…&limit=…). The returned status and generation decide which row applies. The edges are the whole diagnosis:
  • active — accepts update and void. Each accepted update bumps generation by exactly one.
  • voided — terminal. Update and re-void return 409 CONFLICT; issue a replacement.
  • expired — the wallet platform renders the pass expired when its expires_at lapses, but the ledger status still reads active until you void. An expired-but-active pass accepts update, so restoring validity means patching a later expires_at; the void decision is separate.

Why the generation counter drifts

generation counts accepted update events, starting at 0 on issue. Drift is always a stale-read artifact, never a counter bug. When two issuers work on the same contact — one issuing, one polling — a poller that holds a read from before the latest update reports a lower number than the issuer’s sum. Because reads fold the pass’s full event history at request time, a fresh GET always returns the counter’s exact current value; a cached copy does not. Recovery is the same either way: re-issue once under the same idempotency_key (the replay is folded to current state — never stale) and regenerate the holder’s save link from the fresh GET. The holder refreshes their copy on the next open of the new link.

409 CONFLICT on update-after-void vs duplicate-issue idempotency

Two distinct edges produce a 409, and they must not be conflated.
  • Update or void after void. The pass already transitioned to voided. The controller rejects the transition with 409, names the current status, and returns a reason like already_voided. That edge is terminal — the pass can never be revived, so the recovery is to issue a new pass.
  • Duplicate issue. POST /wallet-passes/issue is idempotent: send the same idempotency_key (or the Idempotency-Key header) and the request replays the first issued pass instead of minting a duplicate, returning 200 with replayed: true rather than a conflicting 409. The key lookup and the issue append run inside one transaction, so a double-submit or a client retry can never create two passes.
Read the two edges against the state machine (active accepts, voided rejects) rather than against blanket 409 handling — void’s 409 is the correct answer there; the issue endpoint’s success-based replay is the correct answer for a duplicated enrollment.

Generation is not sequence — the highest observed wins

When two clients issue and then independently update the same pass, a reader that got a snapshot between the issuer’s events might see generation: 3 and then, on a later read, generation: 5 on the same pass. That jump is expected: each accepted update increments by exactly one, and serial issuances across clients mean the counter’s total can only move forward. Never order events by generation as though it were a sequence position — the highest value a reader has observed is the canonical count of accepted updates, and that is the value the wallet uses to decide that the holder needs a refresh.
The Google save link and the Apple .pkpass transport path encode the pass content at mint time. An update after minting leaves the previously minted link resolving to the pre-update snapshot, and a holder’s wallet will not re-fetch until it opens a fresh link. Recovery:
  1. Re-GET the pass — the response rebuilds the platform payloads fresh on every read, so the current platforms.google.save_url (or the Apple serial) now points at the latest content.
  2. Cache-bust the link hand-off: route the new save URL through your link shortener with a fresh slug, or attach a query marker your redirect resolves, so a cached redirect or an analytics page never serves the stale URL.
  3. Re-issued passes (the idempotent-replay path) replay the current pass, so a re-issue under the same key is itself cache-busting — your holders pull the newest content when they open the refreshed link.
Because the ledger read-path rebuilds the platform payloads rather than serving a stored blob, the generation counter is the only thing you need to compare — if it moved, the link you mint already resolves to the current rendering.

What NOT to do

  • Do not use a second, new pass to fix drift. A re-issue with a new idempotency_key mints a distinct pass; the old one still exists. Recovery is always the same key → same pass → newest content.
  • Do not retry a 409 void/update with backoff. The pass is voided on the platform side; the retry can never succeed and it delays issuing the replacement.
  • Do not diff generation across clients. Treat it only as “something moved” on a single pass; compare it across polls, not across issuers.
  • Do not assume a still-active expired pass is safe to void-auto. The pass’s ledger status remains active past expires_at; if you want the audit to read voided, call the void yourself.

When to escalate

Escalate to support@devotel.io only when the ledger-fold read itself misbehaves:
  • A fresh GET /wallet-passes/:id returns a generation lower than the count of accepted updates your client has logged (that would break the fold, and only the platform can re-fold).
  • POST /wallet-passes/:id/update or void returns 409 on a pass whose GET still returns active — divergence between the transition guard and the read projection.
  • An idempotent issue returns replayed: true but the replayed pass shows issued-time content rather than the current lifecycle fold.
Include the request_id from meta.request_id, the wps_ pass id, the status and generation you observed, and the idempotency_key you used.