It's 6:52pm and doors open at seven. A volunteer at a 400-seat community theater has an old iPad, a lanyard, and a line that already reaches the sidewalk. The first guest holds up a phone. The second holds up the same ticket, forwarded by the first guest's cousin. The volunteer has no seating chart, no training, and about four seconds.
Whatever happens next is decided by a server the volunteer will never see. That is the part of a wallet pass program that most teams build last and regret first. Issuing the pass is the easy half. Deciding, at the moment of truth, whether this barcode is good is the half that touches money and trust.
This post walks through how that decision works in the Passmint scanner API: how a device gets its credentials, what one scan call does, how to read the answer, and how to keep a flaky venue Wi-Fi connection from counting anyone twice. The endpoint signatures live in the API docs. Here you get the narrative and the trade-offs.
A scan is a write, not a lookup
Most people picture scanning as reading. The scanner reads a barcode, finds a ticket, and shows a green light. If you build it that way, you will be hurt by the cousin with the forwarded ticket.
The barcode on a wallet pass is just text. Apple's Wallet Passes documentation describes a barcode as a payload the pass displays, and Google's Wallet developer docs treat it the same way. Neither wallet enforces anything about it. By default, Passmint encodes the pass's unique serial number, so the code identifies exactly one pass and carries no other secret. A screenshot of that barcode is as scannable as the real thing. Nothing about the image can tell a first use from a tenth.
So the scan has to change state. Passmint's flow has three steps: scan, verify, redeem. The scanner reads the barcode and sends it up. The server verifies the pass against a short list of conditions. If everything passes, the server redeems it, which means it records the scan and increments the pass's redeemed count in the same operation. The second person to present that code finds the count already moved.
This is also why the check is an online lookup. The scanner needs a connection to ask the question, and the rest of this post covers that path. Test the venue's connection before opening night rather than during it.
Pairing: one code, one device, one token
You could hand every volunteer your API key and call it a day. Please don't. A scanner at a door is the device most likely to be lost, borrowed or left in a bag, and an account-level key on it is a standing invitation.
Passmint gives each scanner its own scoped, revocable token instead. In the dashboard, under Settings, then Scanners, you add a scanner and make three choices:
- A name you will recognize later, like "Front door iPad."
- A scope. The scanner can verify every pass in your workspace, or only passes from one template. Scope the front door to the one event it guards, and a pass from any other template is rejected as out of scope.
- A mode, live or test. A live scanner accepts only live passes. A test scanner accepts only test passes.
Passmint then shows a pairing code, once. It is valid for about 15 minutes. Your device exchanges it for a token, and that call needs no other credential because the code itself is the credential:
curl -X POST https://api.passmint.com/v1/scanners/pair \
-H "Content-Type: application/json" \
-d '{ "code": "PAIRING_CODE_FROM_THE_DASHBOARD" }'The response carries the bearer token, shown once, plus the device's name, mode and template scope, and the policy of the template it guards. Store the token where the scanning app can reach it and nowhere else. If the code expires before you use it, revoke the entry and add a new scanner for a fresh one.
Revoking is the payoff of this design. When the iPad goes missing, you revoke that one scanner from the list and its token stops working the next time it tries to scan. Nobody rotates a key. Nothing else breaks.
One honest note on tooling: there is no dedicated Passmint scanner app yet. Today, a scanner is a device or an integration of your own talking to these two endpoints. A short script on a phone, a till integration, or a small web app all qualify.
The scan call, line by line
Once paired, the whole job is one request. The device sends the text it read and its token:
type ScanResult = {
object: 'scan'
decision: 'accepted' | 'rejected'
reason: string | null
client_scan_id: string | null
pass: { id: string; holder_name: string | null; template_name: string } | null
redeemed_count: number | null
max_uses: number | null
}
export async function scan(code: string, scanId: string): Promise<ScanResult> {
const res = await fetch('https://api.passmint.com/v1/scan', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SCANNER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, client_scan_id: scanId }),
})
if (res.status === 401) throw new Error('Scanner token revoked or invalid')
if (!res.ok) throw new Error(`Scan failed with HTTP ${res.status}`)
return res.json()
}Notice what the function does not do. It does not compare the code to a list, check a date, or count anything. All of that lives on the server, which is the only place you can trust it.
The most important detail is in the status handling. A well-formed scan always returns HTTP 200, whether the pass was accepted or not. The verdict is in the body, in decision. If your code treats a non-200 as "rejected," you will turn a network blip into a turned-away guest, and you will treat a real rejection, which is a 200, as success. Keep the two channels apart. HTTP status tells you whether the call worked. decision tells you whether the guest gets in.
A 401 is the exception that means something. It says the token is invalid or revoked, and no amount of retrying will fix it.
Reading the verdict
An accepted scan returns the pass, with the holder's name and the template it came from, plus the running redeemed count. That is enough to put a name on the screen, which gives the volunteer something to check against a face.
A rejected scan always comes with a reason, and the reasons are the useful part, because each one calls for a different response from the person at the door:
- already_redeemed: a single-use pass has been scanned once already. This is your forwarded ticket. The response includes when it happened, so staff can say "this was scanned at 6:41" instead of "it didn't work."
- count_exhausted: a count-limited pass has used up its allowed scans. Think of the tenth visit on a ten-class package.
- voided: the pass was voided from the dashboard or the API. A voided pass is never admitted, not even by an override.
- wrong_mode: a test pass met a live scanner, or the reverse. This is deliberate, and it is the reason rehearsing can never burn a real ticket.
- out_of_scope: the scanner is locked to one template and this pass belongs to another. Wrong door.
- not_found: the code matches nothing in your workspace.
- ambiguous: the code could not be resolved to a single pass.
Your scanning UI should map these to plain human language and, ideally, different colors. "Already used" and "wrong door" are different conversations. Lumping them into a red X forces the volunteer to guess.
There is one more value, override, which appears on an accepted scan. Some venues want a supervisor to be able to wave a guest through past the policy. Whether a device may do that is part of its setup, and the pairing response tells the device whether overrides are allowed. When one is used, the scan is recorded with override as its reason, so the audit trail shows it was forced.
Pick the policy before the doors open
The redemption policy decides how many scans a pass survives. It is set per template, in the designer, and there are three:
- Single use is valid once. The first accepted scan redeems it and every later scan is rejected. It is the default for new templates and the right answer for most tickets.
- Reusable is always valid. Every scan is accepted and logged, with no limit. A membership card checked at every visit belongs here.
- Count-limited is valid for a fixed number of scans, up to a maximum you set. A 10-class package is the classic case.
The thing to internalize is that the policy belongs to the template, not to the pass. It is not stamped on a pass when you issue it. Passmint evaluates the template's current policy against the pass's redeemed count at the moment of each scan.
That cuts both ways. It means a mistake is fixable. If you issued 300 tickets under a reusable policy by accident, you can switch the template to single use before doors, and the new rule applies to all 300 on their next scan. It also means a careless edit mid-event changes the rules for passes already in pockets. Treat the policy field like a production config value. Change it deliberately, and not while a line is forming.
Retries without double-counting
Here is the failure that bites in the real world. The volunteer scans a ticket. The request reaches the server and the server redeems the pass. The response never makes it back, because the venue Wi-Fi hiccupped. The app sees a timeout and, being helpful, tries again. The second call arrives at a single-use pass that is now redeemed, and the guest with a perfectly valid ticket is told "already redeemed."
The fix is the optional client_scan_id. Your scanner generates a unique ID for each physical scan, and sends the same ID on every retry of that scan. Passmint documents the contract plainly: a retry with the same ID returns the original decision without counting again. The second request gets the first request's answer instead of a fresh evaluation.
async function scanWithRetry(code: string): Promise<ScanResult> {
const scanId = crypto.randomUUID() // one ID per physical scan, not per attempt
for (let attempt = 1; ; attempt++) {
try {
return await scan(code, scanId)
} catch (err) {
if (attempt === 3) throw err
await new Promise((r) => setTimeout(r, 300 * attempt))
}
}
}The one rule that matters is where the ID is created. It goes outside the loop. Generate it per attempt and you have rebuilt the original bug with extra steps.
Do not retry a 401 forever, though. A revoked token will not recover. Surface it so someone can re-pair the device.
Rehearse in test mode, then wire up the event
Test mode exists so you can run the whole door before the real door. Create a test-mode scanner, issue a few test passes, and scan each one twice. You are checking three things: that the second scan of a single-use pass is rejected, that the reason text your UI shows makes sense to a stranger, and that your retry logic holds up when you unplug the router. Because a test scanner only accepts test passes, and a live scanner only accepts live ones, none of this can touch a real ticket.
Afterward, open a test pass and look at its Redemptions tab. It shows the policy and where it stands, the total redeemed, and every scan newest first, with the result, the device name, the reason and the time. Rejected scans are in there too. That log is your answer when a guest says they were turned away unfairly.
The other half of integration is reacting to the door. An accepted scan emits a pass.redeemed event through the same webhook pipeline that delivers install and update events. Subscribe to it to mark attendance, award loyalty points, or open a gate in your own system. A rejected scan does not fire the event. It is logged only, so check the log, not your webhook handler, if you want every attempt.
Where this leaves you
You can build a scanner backend yourself. You would need a pass table with a redeemed counter, an atomic way to increment it so two scanners cannot both win the same ticket, per-device credentials you can revoke, an idempotency key on the scan route, a mode split so rehearsals stay harmless, and an audit log with reasons. None of it is exotic. All of it has to be right on the one night it matters.
If you would rather spend that night on the event, Passmint gives you pairing, the scan endpoint, redemption policies and the redemption log out of the box. Create a test-mode scanner, scan a pass twice, and watch the second one get refused.
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 →