Physical Card Production & Issuance — issuer side
This page is the issuer / provider side (xperise) — what happens inside xperise (and at its manufacturing partners) once physical cards are needed. It is kept separate from the customer-facing flows so the two perspectives do not get mixed up:
| Customer side (corporate admin) | Issuer side (xperise) — this page |
|---|---|
| Corporate Portal — ordering, assigning and controlling cards | Minting PANs, exporting perso files, key management |
| Issue Card flow — issuance form & the order physical cards workflow | Receiving chip UIDs from the bureau, stock accounting |
| Card Management spec | Allocating to a corporate, personalizing the chip, reconciliation |
xperise runs a closed-loop card programme, which makes xperise the issuer — it owns the card number range, the cryptographic keys and the entire card lifecycle. The service that implements this is card-core; printing and personalizing the plastic is done by a perso bureau (the card manufacturer).
This page describes the exact endpoints, states and constraints that card-core implements today
(the physical-cards + chip modules). Several steps of the classic issuer model
(QA, SHIPPED, DELIVERED, PIN mailers, Visa–Mastercard EMV/BIN)
do not exist in the system — see What card-core does not model.
Four state machines to keep apart
The physical card flow is not one single sequence of states, but four independent state machines:
| Object | Table | States |
|---|---|---|
| Print order (one print batch) | card_print_orders | ordered → exported → (canceled) |
| Stock (an individual piece of plastic) | physical_card_stock | status: ordered → available → allocated → activated, or spoiled / retiredperso_state (separate axis): null → PENDING → PERSONALIZED / LOCKED |
| Card (the real card record) | cards | inactive → active → (blocked / expired) |
| Chip credential | card_chips | PENDING → PERSONALIZED / LOCKED → (REVOKED) |
The key point: ordered is not available. An ordered row means "the card number exists but
the plastic does not" — nobody knows its chip UID yet, so the UID necessarily has to come back from
the bureau later. Only once the UID is known does the row become available and eligible for
allocation.
The actual flow
Writing the chip before the corporate activates it (step 4b) is the path to use when you want the
bureau/plant to finish the card completely, and only then hand it to the corporate to activate and
assign a cost center. If you skip step 4b, the credential created at activation is still PENDING
and has to go through a perso station at step 6 as before.
Step by step
1. Create a print order — stock enters ordered
POST /admin/physical-cards/print-orders
Idempotency-Key: <required>
| Field | Type | Required | Description |
|---|---|---|---|
count | number (1–10000) | ✅ | How many cards to print |
expires_at | date-time | ✅ | Expiry printed on the plastic; carried over to the card at activation |
batch_id | string | — | Batch reference the bureau quotes back; auto-generated as PO-XXXXXXXXXX if omitted |
design_code | string | — | Artwork the bureau should print |
include_pan | boolean (default true) | — | Whether the artwork carries the full PAN |
metadata | object | — | Free-form notes |
card-core mints count PANs and count pan_tokens right here, then parks them as ordered
stock with chip_uid = NULL. The PAN prefix is taken in the order
CARD_PAN_PREFIX_PHYSICAL → CARD_PAN_PREFIX → 968686 (an internal pseudo-BIN; no Visa/Mastercard
BIN is required). Setting a dedicated prefix for physical cards is optional but recommended: printed
PANs and internally generated PANs then live in two disjoint ranges and can never collide on the same
number.
This endpoint does not return the perso file. A response carrying the file that gets lost in
transit would lose count card numbers irrecoverably. The PANs are written to the database first;
the file is fetched separately in step 2.
The pan_token is the QR printed on the plastic — an opaque identifier, unique across all stock,
and not a secret. It is the join key for the file the bureau returns in step 3.
2. Export the file for the bureau — order moves to exported
GET /admin/physical-cards/print-orders/{order_id}/file
→ 200 text/csv
RFC 4180 CSV, one row per piece of plastic:
record_no,batch_id,pan_token,pan,pan_last4,expiry,design_code
1,PO-9F2C1A44B8,ptok_Zm9vYmFyYmF6cXV4,9686860000000018,0018,08/29,XPR-BLACK
When include_pan = false the pan column is absent — the plastic then only carries the QR
(pan_token) and last4.
The response includes two headers for reconciliation: X-Record-Count and X-Content-SHA256.
On every download:
- the order moves to
exported, recordingexported_at/exported_by; export_countis incremented andfile_sha256stores the digest of exactly the content just handed over.
The endpoint allows re-exporting (the bureau lost the file, a transfer failed) but never silently
— "who is holding a copy" is always an answerable question. A canceled order returns
409 order_canceled. Already-activated cards automatically drop out of a re-export: their
pan_token has been moved onto the card record and NULLed on the stock row, so there is nothing left
to print.
3. Take the chip UIDs back — stock ordered → available
Once printing is done, the bureau reads each plastic's chip UID and returns a file. card-core ingests that file:
POST /admin/physical-cards/uid-import
→ 200 { imported, skipped, failed, spoiled, order_ids }
{
"chip_generation": "MIFARE_CLASSIC_EV1_1K",
"interface_caps": "CONTACTLESS",
"cards": [
{ "pan_token": "ptok_Zm9vYmFyYmF6cXV4", "chip_uid": "04a1b2c3d4e580" }
],
"spoiled_pan_tokens": ["ptok_YmFkcGxhc3RpYw"]
}
Three design decisions worth noting:
- The join key is
pan_token, not the PAN and not the order. The token is already printed on the plastic as a QR, so the bureau just has to read it — the returned file does not need to contain PANs. The token is unique across all stock, so each row finds its own plastic and brings its own batch with it: a single bureau file spanning several batches can be uploaded in one go. Passorder_idin the body to pin the import to one batch — rows from another batch then fail withwrong_order. chip_generationis mandatory (per row, or as a default for the whole batch). It determines the personalization path — exporting Crypto1 keys only applies to Classic — so plastic with an unknown generation is rejected immediately, rather than stored as NULL and only surfacing at the perso station once the card is already in someone's hands.- Best-effort per row, one transaction per row. A single bad row does not roll back the rest;
the operator only resubmits the
failedportion. A 10k-row file that aborts wholesale is not usable in operations. The endpoint is naturally idempotent (plastic already taken in lands inskipped), so noIdempotency-Keyis needed.
Per-row errors: unknown_pan_token (404), wrong_order (409), invalid_stock_state (409),
chip_bound_elsewhere (409), chip_uid_taken (409), chip_generation_required (422).
spoiled_pan_tokens are the pieces of plastic the bureau ruined. The rows are kept and only
change to status = 'spoiled' — deleting them would return the PAN to the number generator, and
a number that has once been printed must never be issued again. Tokens that cannot be moved (they
do not exist, they are past ordered, or they fall outside the pinned batch) are reported back in
failed with the code not_spoilable, never dropped silently.
POST /admin/physical-cards/print-orders/{order_id}/uid-import does the same job but pins the batch
via the path. It is deprecated — the token on each row already identifies the plastic — and is
kept only so existing bureau integrations and scripts do not break.
4. Allocate to a corporate — stock moves to allocated
POST /admin/physical-cards/allocate
Idempotency-Key: <required>
corporate_id plus one selector:
| Selector | Meaning |
|---|---|
stock_ids | An explicit list of cards |
batch_id | The available cards in that batch |
count (1–1000) | The N oldest available cards — can be combined with batch_id to stay within one batch |
No selector at all → 422 missing_selector. With stock_ids, any row that is not available →
409 stock_not_available. With batch_id/count, too few rows → 422 insufficient_stock; the query
uses FOR UPDATE SKIP LOCKED, so two concurrent allocations never fight over the same plastic.
4b. Pre-personalization — writing the chip before the corporate activates
An optional step for system admins / platform keys. It exists because every byte a perso station
writes used to have to come from a card_chips row, which required a card, which only existed after
the corporate had activated it — forcing the plastic to travel to the corporate blank and come back
for perso.
In reality no byte needs the card record: the diversified key derives from chip_uid, block 4 is
an opaque token, block 5 is (key_version, perso epoch, expiry printed on the card) and block 6 is a
MAC covering those three. What is actually needed is to reserve the token + perso epoch on the
stock row so that activation reuses them verbatim — generating any of those values afresh would
leave a card whose block 6 no longer verifies, i.e. a card that reads as tampered on its very first
tap.
POST /admin/physical-cards/perso/prepare # throttled 60/min, writes a KEY_ISSUE audit entry
POST /admin/physical-cards/perso/confirm
Both take one selector: stock_id, chip_uid, or pan_token (the QR on the plastic — the most
convenient when the card is physically in hand). No selector → 422 missing_selector.
prepare returns exactly what the station writes onto the card:
| Field | Description |
|---|---|
card_token | Block 4 — an opaque token, 16 bytes hex. Not the PAN |
key_version, kdf_ref | Handles for the diversified key in the HSM — never the actual key |
perso_epoch | The perso timestamp covered by block 5; fixed at reservation time |
issuer_block, expected_mac, counter_block | Contents of block 5 / 6 / counter — Classic only |
sector_keys | Crypto1 keys + trailers for all 16 sectors — Classic only, null for DESFire |
layout | Where each field sits on a Classic 1K — read it from here, do not hardcode |
It only accepts plastic that is available or allocated (writing before or after allocation both
work) and that already has a chip_uid — plastic that has not been through uid-import does not yet
exist physically, so there is no UID to diversify the key from → 409 chip_unknown. Plastic that is
already activated → 409 invalid_stock_state: the card exists and owns its credential, so perso
goes through /cards/{card_id}/chip/perso/* at step 6.
Idempotent per piece of plastic: the token and perso epoch are reserved once and echoed back on
every subsequent call, so rewriting after a failure yields exactly the same bytes rather than a
second personalization. Once written, call confirm with result = WRITTEN (or LOCKED — also a
success, the station simply sealed the card at the same time) → stock.perso_state moves to
PERSONALIZED / LOCKED. If a write fails, do not confirm: the reservation stands, just call
prepare again.
perso_state is a separate axis, not status — the plastic stays stock until the corporate
activates it. Both values appear in GET /admin/physical-cards.
prepare hands out live keysOn Classic, the response carries real Crypto1 keys. Hold them in memory for one card; do not log
them, do not write them to disk, do not forward them — exactly as with POST /chip/keys. Every call
lands in chip_auth_logs with mode = KEY_ISSUE (no credential_id, since the card does not exist
yet; the log row is keyed by chip_uid + card_token). DESFire only ever receives kdf_ref —
DESFire keys never leave the HSM.
5. The corporate admin turns stock into a real card
POST /physical-cards/activate
Idempotency-Key: <required>
The body inherits every issuance field of CreateCardDto (user_id — omitted or '0' makes it
a bearer card, cost center, limits, verification_preference, funding_source,
payment_channels, topup_mode, …) plus a stock selector:
| Field | Description |
|---|---|
stock_id or chip_uid | The stock card to activate (one of the two) |
applet_aid | The AID (hex) for chip personalization, e.g. F51840 |
Inside a single transaction, card-core:
- Locks the stock row and checks that it belongs to the caller's corporate and is
allocated. Stock that does not exist or is out of scope both return404— existence is never leaked through a403. - Creates the
cardsrecord withform_factor = 'physical', reusing the PAN/token/expiry already printed on the plastic verbatim (nothing is regenerated, and the PAN is never revealed again because it is already printed on the card). QR-only plastic (include_pan = false, no PAN) falls back to the internal PAN generation path. Cardholder / bearer / limits / static_code validation is identical to virtual cards. - Binds the
PHYSICALchip credential. Plastic that was not pre-personalized → the credential mints its own token and sits atperso_state = PENDING. Plastic that was written at step 4b → the credential reuses thecard_token+ perso epoch already on the chip verbatim and goes straight toPERSONALIZED/LOCKED(the counter is seeded) — tappable without a trip to a perso station. A reservation nobody confirmed still reuses the token (the chip may already be carrying it) but staysPENDINGso a perso station can confirm later. - Moves the card material onto the card record: stock goes to
activated, andpan_token/pan_hash/encrypted_panare NULLed on the stock row — from here on the card is the single source of truth.
The response returns the card, stock_id, chip_uid, and chip.perso_state (PENDING, or
PERSONALIZED/LOCKED if it was pre-personalized). The pan field appears only when card-core
generated the PAN itself (QR-only plastic); an already-printed PAN is never echoed back.
This step creates the card at status = inactive. The card only becomes spendable after step 7.
The name "activate" here refers to the stock (allocated → activated), not to the card.
6. Personalization: loading keys onto the chip — PENDING → PERSONALIZED
The freshly created credential is still PENDING: the diversified key is not on the chip yet, so the
card cannot tap (chip-tap rejects it with credential_not_active). A perso station — a desk
station or an Android phone — runs four calls:
POST /cards/{card_id}/chip/perso/job # back office: issues a job token + QR (platform / system admin)
POST /cards/{card_id}/chip/perso/prepare # perso station: fetches card_token + kdf_ref
POST /chip/keys # the Crypto1 key set for one Classic card (throttled 60/min, audited)
POST /cards/{card_id}/chip/perso/confirm # result → PERSONALIZED or LOCKED
The station holds a platform key; the phone holds a perso job token scanned from the QR — one card, a few minutes, consumed at confirm. Issuing a new job closes any open job for the same card: a new printout must invalidate the one it replaces, otherwise two operators could work the same card from two different QRs.
POST /chip/keys is the only surface that hands out live keys, so every call lands in
chip_auth_logs with mode = KEY_ISSUE.
A confirm with result = LOCKED is also a successful ending (perso completed and the card sealed),
not a kill — both PERSONALIZED and LOCKED can tap.
7. The cardholder activates the card — inactive → active
The only door that "switches the money on":
POST /my/cards/{card_id}/activate # starts an OTP challenge
POST /my/otp-challenges/{id}/confirm # enter the code → status = active
Before this step the card is inert: a piece of plastic lost in transit cannot authorize anything.
The system does not use PINs — so there is no PIN mailer to print or send either. The CVM is
verification_preference: qr_app (default), sms_otp, or static_code. See
Transaction authentication (CVM).
8. Companion perso — a physical card for an existing virtual card
A different case: the bureau prints plastic carrying the PAN of an existing (virtual) card, not one drawn from stock.
POST /physical-cards/perso-return
→ 200 { bound, skipped, failed }
Here the mapping key is the full PAN (via pan_hash — globally unique, so it matches exactly one
card), and a PHYSICAL credential is attached to that card: no new card, no new PAN.
keys_loaded (default true) states whether the bureau already loaded the diversified key onto the
chip → PERSONALIZED (tappable immediately, counter seeded) or PENDING (to be confirmed at a perso
station later).
The PAN must be 16 digits and Luhn-valid, otherwise 422 invalid_pan. Best-effort per row and
naturally idempotent (a chip already bound to that card → skipped), so no Idempotency-Key is
needed. A corporate admin can only operate within their own cards; a system admin can do this for
any corporate.
Key management
Chip taps keep their secret in the secure element. card-core derives a per-card key from the
issuer master key, diversified by chip_uid:
- The master key is fetched through KeyRing (
chip-master). Today that isCHIP_ISSUER_MASTER_KEYin the environment (EnvKeyRing); switching to KMS/HSM requires no caller changes. kdf_ref(the handle of the diversified key) is stored on the credential; the real key is never in the perso file — the perso file only holdspan_token, PAN (if the artwork prints it), last4, expiry and design.- Because
chip_uidis only knowable after the plastic exists, the key cannot be pre-baked into a QR job or into the print file — which is why step 3 has to be structured as "the UID comes back" rather than "the UID is sent out".
This is what makes every tap produce a different cryptogram, and what makes the card impossible to clone.
PAN security
| Mechanism | Detail |
|---|---|
| Storage | encrypted_pan (ciphertext) + pan_hash for lookups + pan_hash_key_version for rotation |
| Single exit path | The file export in step 2 — counted (export_count) and digested (file_sha256) |
| Never revealed again | A printed PAN is never returned through the API; only a card-core-generated PAN is shown once, in the activation response |
| No number reuse | Ruined plastic keeps its row at spoiled; deleting the row would return the PAN to the generator |
| Range exhaustion guard | Minting retries in rounds (up to 8), each round only regenerating the shortfall; out of rounds → 503 pan_allocation_failed (the range is too tight and needs a new BIN rather than a retry) |
Chip: generations & interfaces
chip_generation | Notes |
|---|---|
DESFIRE_EV1, DESFIRE_EV2, DESFIRE_EV3 | EV2/EV3 enable proximity_check_enabled once PERSONALIZED |
MIFARE_CLASSIC_1K, MIFARE_CLASSIC_EV1_1K | The Crypto1 path — only Classic exports keys via POST /chip/keys |
interface_caps: CONTACTLESS (default), CONTACT, DUAL.
This is MIFARE, not EMV. There is no Visa/Mastercard BIN, no EMV applet and no scheme certificates — it is closed-loop, so xperise defines all of it itself.
The two artwork variants
PAN printed on the card (include_pan: true) | QR only (include_pan: false) | |
|---|---|---|
| Export file | Has a pan column | No PAN in the file |
| The plastic carries | PAN + last4 + QR | Only the QR (pan_token) + last4 |
| Card's PAN at activation | Reuses the printed PAN | card-core generates an internal PAN |
| Risk | The PAN leaves the system with the file | The PAN never leaves the system |
Both tap identically — pan_token is only an identifier, and payment always requires
CVM.
What card-core does not model
Worth knowing explicitly, so no documentation or UI is built on states that do not exist:
| Item | Current state |
|---|---|
QA, SHIPPED, DELIVERED | Absent from card_print_orders (only ordered / exported / canceled). QA and shipping currently sit outside card-core |
| Goods-receipt reconciliation | There is no "receive" endpoint; reconciliation is effectively the uid-import step (imported / skipped / failed / spoiled) |
retired (stock) | Present in the enum but no endpoint sets it yet |
| Reissue on expiry / replacing a lost card | No dedicated flow yet — today you revoke the credential and re-run print order → allocate → activate |
| PIN | Does not exist (see step 7) |
| Shipping model (bulk to the company vs drop-ship to the employee) | An operational decision, not reflected in the data |
API reference
Base URL (staging): https://api-staging-core-card.uts.network
| Endpoint | Permission | Idempotency-Key |
|---|---|---|
POST /admin/physical-cards/print-orders | System admin / platform | ✅ |
GET /admin/physical-cards/print-orders | System admin / platform | — |
GET /admin/physical-cards/print-orders/{order_id} | System admin / platform | — |
GET /admin/physical-cards/print-orders/{order_id}/file | System admin / platform | — |
POST /admin/physical-cards/uid-import | System admin / platform | — |
POST /admin/physical-cards/print-orders/{order_id}/uid-import | System admin / platform | — (deprecated) |
POST /admin/physical-cards/allocate | System admin / platform | ✅ |
POST /admin/physical-cards/perso/prepare | System admin / platform (throttled 60/min) | — |
POST /admin/physical-cards/perso/confirm | System admin / platform | — |
GET /admin/physical-cards · GET /admin/physical-cards/{stock_id} | System admin / platform | — |
GET /physical-cards | Corporate admin (scoped to their corporate) | — |
POST /physical-cards/activate | Corporate admin | ✅ |
POST /physical-cards/perso-return | Corporate admin / platform / system admin | — |
POST /cards/{card_id}/chip/perso/job | Platform / system admin | — |
POST /cards/{card_id}/chip/perso/prepare · .../confirm | Perso station / platform | — |
POST /chip/keys | Perso station / platform (throttled 60/min) | — |
POST /my/cards/{card_id}/activate | Cardholder (OTP) | — |
Stock list query: status, corporate_id, batch_id, cursor, limit (1–100, default 20).
Print order list query: status, batch_id, cursor, limit. Both paginate by the cursor
base64url(created_at|id), newest first.
Related
- Issue Card flow — the customer-facing order physical cards workflow that the steps above sit behind.
- Card Management spec — the full reference for card functionality.
- Card Payment — card lifecycle, CVM, acceptance methods.
- Card activation — the details of step 7.