Skip to main content

Merchant app: MIFARE Classic tap → payment

How a merchant mobile app reads a personalized MIFARE Classic 1K card and turns that tap into a payment on the merchant API (entry_mode=chip_tap).

The personalization side — how the card got its keys and its data blocks — is perso-android.md. This document is the read side, and it assumes the card is already PERSONALIZED or LOCKED.


0. Read this before you build anything​

Three facts decide whether this integration is possible at all, and they are all load-bearing.

1 — Android only, and only on NXP silicon. android.nfc.tech.MifareClassic works only where the NFC controller implements Crypto1.

val supported = packageManager.hasSystemFeature("com.nxp.mifare")

Most Samsung handsets pass; Pixels and many Xiaomi models do not. iOS cannot do this at all — CoreNFC exposes no Crypto1 authentication, so a Classic sector can never be read from an iPhone. The fallback for both is an ACR122U/ACR1252U over USB OTG, which moves Crypto1 into the reader and makes the handset's chipset irrelevant.

2 — The tap proves the card, never the cardholder. Crypto1 keys are recoverable, so a cloned chip produces a cryptogram that verifies. Every chip_tap authorize therefore lands in pending_verification and settles only after an SMS OTP or the card's printed PIN is submitted to POST /merchant/payments/{auth_id}/verify. There is no single-tap payment on this rail and there will not be one — do not design a UI that assumes tap-and-go.

3 — The app never holds the key that signs a tap. A merchant session reads a card through two endpoints, and neither one emits the signing key:

EndpointCredentialEmits
POST /merchant/chip/tap/preparemerchant sessiontoken-sector Key A, counter-sector Key B, expected block contents
POST /merchant/chip/tap/cryptogrammerchant sessiona nonce and a server-computed cryptogram
POST /chip/keysplatform onlythe full 16-sector bundle + auth_key

POST /chip/keys is the perso surface and stays platform-only: platform key (X-API-Key), Store system-admin token, or a single-card perso job token (X-Perso-Job). A merchant credential there gets 401 unauthorized, and that is deliberate — see §1 for what an app would be holding if it did not.

The generated OpenAPI lists corporateAdmin/xperise on POST /chip/keys too. That is an artifact of @ApiSecurity decorators accumulating from the controller class; the guard metadata on the handler overrides it. Trust the three credentials above.

Migration required​

chip_tap was missing from the authorizations_entry_mode_check constraint, so every tap authenticated, advanced the counter, wrote its audit row, and then died at insert with a check violation — 500 internal_error on both the approve and the decline branch. Fixed by AddChipTapEntryMode1784878600000; run npm run migration:run on any environment that predates it. The same constraint was blocking the platform reader surface (POST /chip/authorize), so that path is unblocked by the same migration.


1. Where the keys live​

Two actors, and the split between them is the whole security design.

ActorHoldsNever holds
Handset (merchant app)merchant session token; for the duration of one tap: token-sector Key A, counter-sector Key Bthe platform key, auth_key, the other 14 sectors' keys, any sector trailer
card-corethe issuer master key (HSM in production); derives everything per tap—

Crypto1 keys are diversified per chip_uid, they cannot be rotated over the air, and re-keying means physically re-personalizing the fleet. That is why a platform key must never ship inside an APK: one extracted APK would be a fleet recall.

card-core hosts the key broker, so you do not have to build one. Earlier revisions of this document told integrators to stand up their own backend holding X-API-Key and proxy /chip/keys per tap. That is no longer necessary — and the hosted version is narrower than a hand-rolled proxy, because the signing key never crosses the network at all.

handset card-core
│ tap → read UID │
├── POST /merchant/chip/tap/prepare {chip_uid} ──────────────────►│
│◄── {card_token, expected_mac, issuer_block, layout, ────────────┤
│ token_key_a, counter_key_b, key_version} │ 2 keys of 32.
│ │ no auth_key,
│ read blk 4/5/6, check MAC against expected_mac │ no trailers.
│ increment counter on-card → N, read back to confirm │
│ │
├── POST /merchant/chip/tap/cryptogram {chip_uid, counter: N} ───►│
│◄── {nonce, cryptogram, key_version, card_token, counter} ───────┤ CMAC here,
│ │ with auth_key
├── POST /merchant/payments/authorize entry_mode=chip_tap ──────►│ never leaving.
│ │
├── POST /merchant/payments/{auth_id}/verify {code} ────────────►│ ← the real gate

Two calls per tap is intentional. Each writes a chip_auth_logs row — KEY_ISSUE for the prepare, CRYPTOGRAM_ISSUE for the signature — carrying the sub-account that asked, which is the per-terminal trail for key handout.

What the cryptogram does and does not prove. It proves the tap was brokered by card-core for this merchant session. It does not prove plastic was present: a caller that already knows a chip_uid can ask for a signature without a card. What actually stands behind a chip_tap payment is the counter — which the card owns, and which regresses into chip_counter_replay on the next genuine tap if it was fabricated — and the cardholder OTP/PIN that every chip_tap authorization is gated behind (§0.2). Design accordingly: the tap identifies the card, /verify authorizes the payment.

The handset writes no key material to disk, ever. Keys live in memory for one tap and are zeroed after.


2. What is on the card​

Layout comes from layout in the /prepare response — read it, do not hardcode it. Today's values:

BlockSectorContentReadWrite
41card_token — opaque 16 bytesKey A or BKey B
51issuer_block — metadataKey A or BKey B
61expected_mac — AES-CMAC over uid ‖ blk4 ‖ blk5Key A or BKey B
82counter — a MIFARE value blockKey A or Bwrite & increment: Key B · decrement/transfer: A or B
3, 7, 11, …allsector trailersnever readableKey B

All 16 sectors are re-keyed at personalization — nothing is left on FFFFFFFFFFFF, so the app authenticates every sector it touches with a key from the bundle.

issuer_block is fixed-layout big-endian, parse it without a schema:

key_version(1) │ perso_epoch(4) │ expiry_epoch(4) │ crc16-ccitt(2) │ rfu(5)

The CRC is a transcription check only. Integrity is expected_mac's job: the UID is inside the MAC message, so a payload lifted off one card does not verify on another.

The counter is a value block in the on-card format the increment/transfer commands require:

value(4 LE) ‖ ~value(4 LE) ‖ value(4 LE) ‖ addr ‖ ~addr ‖ addr ‖ ~addr

addr is the block index (0x08 for block 8, so 08 F7 08 F7).


3. The tap sequence​

1 tag discovered → read UID (chip_uid)
2 POST /merchant/chip/tap/prepare {chip_uid}
→ card_token, expected_mac, issuer_block,
layout, token_key_a, counter_key_b, key_version
3 authenticate token sector with Key A
read blocks 4, 5, 6
assert blk6 == expected_mac ← not our card / tampered → abort the tap
assert blk4 == card_token
4 authenticate counter sector with Key B
read value block → v
increment(counter_block, 1); transfer(counter_block)
read back → v+1 ← this is the `counter` you present
5 POST /merchant/chip/tap/cryptogram {chip_uid, counter: v+1}
→ nonce, cryptogram, key_version
6 POST /merchant/payments/authorize entry_mode=chip_tap
7 collect the OTP / PIN → POST /merchant/payments/{auth_id}/verify
8 pre-auth only: POST /merchant/payments/{auth_id}/capture

Step 4 before step 5, always, and only with a counter you read back off the card. The on-card counter is the source of truth; the server only remembers the highest value it has ever verified. /cryptogram refuses to sign a counter that is not ahead of that (409 counter_replay) — which is a retryable error, whereas the same counter smuggled into /authorize is a chip_counter_replay decline that revokes the credential and takes the plastic out of service.

Android​

MifareClassic authenticates per sector, not per block — convert with blockToSector(block) and authenticate once per sector.

val mfc = MifareClassic.get(tag) ?: error("this handset cannot do Crypto1")
mfc.use {
it.connect()
it.timeout = 5_000

// ── token sector: read-only with Key A ──
val tokenSector = it.blockToSector(prep.layout.tokenBlock)
check(it.authenticateSectorWithKeyA(tokenSector, prep.tokenKeyA.hexToBytes())) {
"auth failed on sector $tokenSector"
}
val cardToken = it.readBlock(prep.layout.tokenBlock).toHex()
val issuer = it.readBlock(prep.layout.issuerBlock).toHex()
val mac = it.readBlock(prep.layout.macBlock).toHex()
require(mac.equals(prep.expectedMac, true) && cardToken.equals(prep.cardToken, true)) {
"card did not match the issuer's record — refuse the tap"
}

// ── counter sector: increment needs Key B ──
val ctrSector = it.blockToSector(prep.layout.counterBlock)
check(it.authenticateSectorWithKeyB(ctrSector, prep.counterKeyB.hexToBytes()))
val before = valueOf(it.readBlock(prep.layout.counterBlock))
it.increment(prep.layout.counterBlock, 1)
it.transfer(prep.layout.counterBlock)
val counter = valueOf(it.readBlock(prep.layout.counterBlock))
require(counter == before + 1) { "counter did not advance" }
}
/** Parse a value block, validating the redundant copies the chip enforces. */
fun valueOf(b: ByteArray): Int {
fun le(off: Int) = ByteBuffer.wrap(b, off, 4).order(ByteOrder.LITTLE_ENDIAN).int
val v = le(0)
require(v == le(8) && le(4) == v.inv()) { "block 8 is not a valid value block" }
return v
}

Some controllers refuse increment/transfer on a value block. The fallback is a plain writeBlock of the re-assembled value block (write is Key B, which you already authenticated with) — build the bytes with the format in §2.

Drive the tag with reader mode, not foreground dispatch: it suppresses NDEF handling and the system tap sound, and keeps the tag alive across the read.

nfcAdapter.enableReaderMode(
activity, callback,
NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
Bundle().apply { putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250) },
)

4. The cryptogram​

You do not compute this — POST /merchant/chip/tap/cryptogram does, and returns it ready to paste into the authorize body:

POST /merchant/chip/tap/cryptogram
Authorization: Bearer <access_token>

{ "chip_uid": "04a1b2c3", "counter": 7 }

→ 200 { "nonce": "b3c1a95e77d240f8ac1e6b02d95f4c81",
"cryptogram": "3b1d7c9e5f2a48d06e8c1b4a7f309d52",
"key_version": 1,
"card_token": "9f86d081884c7d659a2feaa0c55ad015",
"counter": 7 }

The construction, for reference when debugging a decline:

cryptogram = AES-128-CMAC(auth_key, nonce ‖ counter ‖ card_token)
FieldBytesNotes
nonce16server-minted per call, hex. Single-use per credential
counter4uint32 big-endian — the on-card value after the increment
card_token16block 4, hex (32 lowercase hex chars)
auth_key16AES-128, derived from chip_uid + key_version. Server-side only — no merchant endpoint returns it

The reference CMAC lives in chip-crypto.util.ts, and the signing path is ChipService.merchantTapCryptogram.

Rules the server enforces on the resulting authorize, in this order — the first failure wins:

  1. card_token resolves to a credential (else 404 not_found)
  2. credential is not revoked, and is PERSONALIZED or LOCKED
  3. (credential, nonce) has never carried a verified cryptogram — nonce is single-use
  4. the cryptogram verifies against the re-derived key
  5. counter is strictly greater than the highest counter recorded for the credential. chip_counters.last_counter is seeded to 0 at personalization, so the first ever tap must present ≥ 1

Pass key_version through from the /cryptogram response. Omitting it makes the server fall back to the credential's stored version, which is right today but silently wrong the moment a card is re-personalized at a new version.


5. The payment calls​

Authentication​

The same two credentials authenticate the tap endpoints in §1 and the payment endpoints here:

CredentialHeaderUse
Merchant sessionAuthorization: Bearer <access_token>the mobile app
Parent machine keyX-Merchant-Key: <api_key>server-to-server only

Never ship X-Merchant-Key in an app — it is the parent merchant's machine credential and it is not a Bearer token. Devices log in per operator:

POST /merchant/login ← public, throttled to 10/min
{ "alias": "cafe-hcm", "username": "pos-07",
"password": "5f4dcc3b5aa765d61d8327deb882cf99" }

→ 200 { "access_token": "…", "expires_in": 900, "refresh_token": "…",
"sub_account": { "sub_account_id": "…", "role": "operator" },
"merchant": { "merchant_id": "…", "name": "…" } }

password is the client-side MD5 hex digest of the raw password, not the password itself — the backend never sees plaintext. That also means the app owns password-strength enforcement before hashing: at least 10 characters using 3 of the 4 classes (upper, lower, digit, symbol).

The refresh token comes back in the body (no httpOnly cookie) precisely so native clients can store it. 401 token_expired means refresh; anything else means re-login. Both admin and operator may authorize; an operator sees only its own rows in GET /merchant/payments.

There is a second reason to log in per device: the sub-account id is written to chip_auth_logs.reader_id on both tap calls, so a device session gives you per-terminal attribution for every key handout and every signature. A parent-key call leaves it null — which is the only after-the-fact control there is over a device that starts pulling keys it should not.

Authorize​

POST /merchant/payments/authorize
Authorization: Bearer <access_token>
Idempotency-Key: 7d9f4e21-3a54-4a1e-9f0c-8b2d5c6e1a04 ← required (422 without it)
Content-Type: application/json

{
"entry_mode": "chip_tap",
"card_token": "9f86d081884c7d659a2feaa0c55ad015",
"cryptogram": "3b1d7c9e5f2a48d06e8c1b4a7f309d52",
"counter": 7,
"nonce": "b3c1a95e77d240f8ac1e6b02d95f4c81",
"key_version": 1,
"amount": "120000",
"currency": "VND",
"external_ref":"POS-000481",
"auto_capture": true,
"mcc": "4121"
}
  • card_token must be the chip's own token — 32 lowercase hex. A ptok_… or a PAN here is 422 validation_failed, deliberately: the shapes are pinned so one credential cannot be presented as another.
  • amount is a decimal string, ^\d{1,16}(\.\d{1,2})?$. VND has no minor units, so "120000" is 120 000 ₫. Never a float.
  • external_ref is required and is also recorded as chip_auth_logs.client_tx_id.
  • auto_capture: true → the sale captures inside /verify. false → /verify creates a hold (48h by default) and you capture later.
  • enhanced_data / metadata are accepted here as on every other entry mode; if the merchant has enhanced_data_required, capture enforces it.

Response — 201, always pending_verification on the happy path:

{
"auth_id": "0f3c…",
"status": "pending_verification",
"capture_id": null,
"decline_reason": null,
"amount": "120000",
"currency": "VND",
"card_id": "6f1c…",
"corporate_id": "corp-1",
"external_ref": "POS-000481",
"attribution": { "user_id": "user-123", "cost_center_id": "CC-SALES-HCM", "trip_id": null, "trip_match": "unassigned", "department": "Sales", "corporate_id": "corp-1" },
"policy_check": "evaluated",
"hold_expires_at": null,
"verification": {
"method": "sms_otp",
"expires_at": "2026-07-30T08:12:00.000Z",
"attempts_remaining": 5,
"phone_masked": "****1234"
},
"created_at": "2026-07-30T08:09:00.000Z"
}

verification.method decides the UI:

  • sms_otp — a 6-digit code was texted to the cardholder, naming the exact amount and merchant. TTL 5 minutes, 5 attempts.
  • static_code — no SMS. Ask for the PIN printed on the card. Same TTL and attempt budget.

A decline is also 201 with status: "declined" and a decline_reason; only broken requests are 4xx/5xx. No hold is placed for a pending chip_tap/manual_pan/card_qr auth, so an abandoned OTP locks no funds, and a new pending auth for the same (card, merchant) auto-voids the previous one.

Verify​

POST /merchant/payments/{auth_id}/verify
{ "code": "482913" }

200 in every ordinary outcome — read status:

statusMeaning
captured (+ capture_id)auto_capture: true, money moved
approved (+ hold_expires_at)pre-auth, hold placed — capture next
pending_verificationwrong code; verification.attempts_remaining decremented
declined3rd wrong code (card_locked_too_many_attempts, and the card's manual/QR/tap entry is locked for 15 minutes), sale past its verification_deadline (verification_expired), wallet or funds refusal

The one ordinary outcome that is NOT a 200: a code submitted after verification.expires_at is 422 otp_expired. That kills the code, not the sale — the auth stays pending_verification until verification.verification_deadline.

No Idempotency-Key on this call. Re-verifying an already settled auth is not an error — it replays the current state.

Resend the code​

POST /merchant/payments/{auth_id}/resend-verification

For "the SMS never arrived" and for recovering from otp_expired. Re-arms the same challenge — new code, fresh expires_at, attempts_remaining back to 5 — and returns the auth with its updated verification block. The previous code stops working immediately.

It costs no velocity slot (that gate counts challenges per card, and this opens none), so it carries its own limits instead: 429 resend_too_soon inside the 30s cooldown (details.retry_after_seconds), 422 resend_limit_reached after 2 resends, 422 resend_not_applicable for static_code cards. Past the sale's deadline it returns the auth declined verification_expired — start a new payment.

No Idempotency-Key here either: replaying a stored response would mean not sending the SMS the POS asked for. The cooldown is what makes a double-tap safe.

Capture / void​

POST /merchant/payments/{auth_id}/capture
Idempotency-Key: <new key>
{ "amount": "120000", "final": true } → 201 capture
POST /merchant/payments/{auth_id}/void → 200 (approved or pending only)

Refunds go against a capture: POST /merchant/captures/{capture_id}/refunds. Note the wallet accepts one refund per payment — a payment refunded once, fully or partially, cannot be refunded again (422 wallet_payment_already_reversed).


6. Declines and errors​

Chip-specific declines — 201, status: "declined":

decline_reasonCauseWhat the app should do
chip_cryptogram_invalidCMAC did not verify — wrong key, wrong message order, wrong counter encodingintegration bug; do not retry the tap
chip_nonce_replayedthis nonce already carried a verified cryptogramfetch a fresh nonce per tap
chip_counter_replaycounter did not advance — the clone signalsee the warning below
chip_credential_revokedcredential is dead (lost/stolen/compromised)tell the cardholder to contact the issuer
chip_credential_not_activestill PENDING — personalization never confirmedcard needs re-personalizing
channel_not_allowedthe card's contactless_tap channel is offanother entry mode, or ask the corporate admin
card_blocked / card_inactive / card_expiredcard state—
card_outside_validity_windowthe card is outside the windows of time its corporate admin allows it to pay inanother card; the admin sets the card's validity_windows
card_locked_too_many_attempts15-minute lock from 3 failed codesretry after the lock
velocity_limit_exceededmore than 3 challenges for this card in 5 minuteswait
insufficient_funds, per_transaction_limit_exceeded, daily_limit_exceeded, monthly_limit_exceeded, mcc_not_allowed, merchant_not_allowed, policy reasonsordinary money-path declines, all raised by card-core itselfshow and stop
service_payment_insufficient_fundsthe Xperise wallet is empty — not the card's limit, despite the similar nameshow and stop; the cardholder tops up the wallet, not the card
other service_payment_*the wallet refused or was unreachable when /verify settled the chargesee §4.9 of merchant-authorize-errors.md — service_payment_in_flight is the one worth retrying

chip_counter_replay revokes the credential. With CHIP_AUTO_REVOKE_ON_REPLAY at its default (true), a counter regression kills the credential in the same transaction as the decline — the card stops working and must be re-personalized. An app bug that replays counters therefore bricks real plastic. Run staging with CHIP_AUTO_REVOKE_ON_REPLAY=false while integrating, and turn it back on before production.

Tap-broker errors — these come back from /merchant/chip/tap/*, before any payment exists:

HTTPcodeMeaningWhat the app should do
404credential_unknownno card is registered for this chip_uidnot our card — abort the tap
409credential_revokedcredential is deadtell the cardholder to contact the issuer
409credential_not_activestill PENDING — personalization never confirmedcard needs personalizing
409not_classica DESFire credential; its keys stay in the HSMnot readable this way
409counter_replaythe counter presented to /cryptogram is not ahead of the last verified tapre-read the counter off the card; do not send it to authorize
429rate_limited120 calls/min, so ~60 tapsback off

Payment errors — { "code": …, "message": … }:

HTTPcodeMeaning
401token_expiredrefresh the merchant session
401unauthorizedbad merchant key
404not_foundcard_token resolves to no credential
409idempotency_conflictsame Idempotency-Key, different body
409request_in_flightthe original call is still committing — retry unchanged
422validation_failedbad card_token shape, or chip_tap without cryptogram/counter/nonce
422missing_idempotency_keyheader absent on authorize/capture/refund
422verification_not_pending/verify on an auth with no open challenge
422static_code_unavailablecard prefers static_code but has no PIN on file
422merchant_mismatchbody merchant_id ≠ the authenticated merchant
429rate_limitedthrottled
502otp_delivery_failedSMS gateway refused; nothing was authorized

7. Retry and failure rules​

A timed-out authorize is not a failed authorize. Retry the identical body with the same Idempotency-Key: same key + same payload replays the original response, same key + different payload is 409 idempotency_conflict. Never build a fresh tap (new nonce, new counter) to retry a call that may have landed — the retry has to be byte-identical.

A genuine re-tap is a new payment attempt: new nonce, another on-card increment, and a new Idempotency-Key. It auto-voids the previous pending auth for that (card, merchant).

Counter drift is one-directional and harmless. If the app increments the card and the network call never arrives, the card is ahead of the server — the next tap still presents a higher value and passes. The dangerous direction is a counter the card never took, which is why the increment must be confirmed by a read-back before you present it.

A rejected cryptogram does not move the stored counter. The counter advances only after the cryptogram verifies, so a chip_cryptogram_invalid decline leaves the server exactly where it was and the next genuine tap continues from the next value. There is nothing to reset.

Tag lost mid-sequence. Nothing has moved: the token sector is read-only to the app and an interrupted increment/transfer either took or did not. Read the counter again on the next tap and continue.

Key hygiene. The two sector keys live in memory for one tap and are zeroed after. No disk, no logs, no crash reporter, no analytics payload. /prepare responses must never be cached — a cached one is a Crypto1 key at rest on a device you do not control, and Classic keys cannot be rotated over the air.


8. Testing without plastic​

The whole path is exercisable with no NFC hardware at all — and, past the setup step, with nothing but a merchant login.

# 1. bind a credential to a card (any UID you like) and mark it personalized
POST /cards/{card_id}/chip/perso/prepare X-API-Key
{ "chip_uid": "04a1b2c3", "chip_generation": "MIFARE_CLASSIC_EV1_1K",
"interface_caps": "CONTACTLESS", "applet_aid": "" }
POST /cards/{card_id}/chip/perso/confirm X-API-Key
{ "credential_id": "…", "chip_uid": "04a1b2c3",
"result": "WRITTEN", "key_loaded": true }

# 2. log a terminal in
POST /merchant/login (public)
{ "alias": "cafe-hcm", "username": "pos-07", "password": "<md5 hex>" }

# 3. what the app would read off the card
POST /merchant/chip/tap/prepare Bearer
{ "chip_uid": "04a1b2c3" }

# 4. sign a tap — counter starts at 1, chip_counters is seeded to 0 at confirm
POST /merchant/chip/tap/cryptogram Bearer
{ "chip_uid": "04a1b2c3", "counter": 1 }

# 5. POST /merchant/payments/authorize with entry_mode=chip_tap
# 6. POST /merchant/payments/{auth_id}/verify

Bump counter by one on each subsequent run — step 4 refuses to re-sign a value the previous run already got through authorize.

The platform equivalent still exists if you want the full bundle (all 16 sectors, auth_key, the trailers): POST /chip/keys with X-API-Key, then compute the CMAC yourself per §4.

For step 5, either set the card's verification_preference to static_code and submit the printed PIN, or leave it on SMS and read the code out of the server log — with NOTIFICATION_CENTER_ORIGIN unset the OTP is logged instead of sent.

For the real thing on plastic, personalize a scrap card with scripts/perso-classic.js first; it prints every block it writes, which is the fastest way to confirm your app is reading the right ones.