Skip to main content

Physical Card Production & Issuance — issuer side

Customer side vs 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 cardsMinting PANs, exporting perso files, key management
Issue Card flow — issuance form & the order physical cards workflowReceiving chip UIDs from the bureau, stock accounting
Card Management specAllocating 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 is the flow that exists in code, not a generic model

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:

ObjectTableStates
Print order (one print batch)card_print_ordersordered → exported → (canceled)
Stock (an individual piece of plastic)physical_card_stockstatus: ordered → available → allocated → activated, or spoiled / retired
perso_state (separate axis): null → PENDING → PERSONALIZED / LOCKED
Card (the real card record)cardsinactive → active → (blocked / expired)
Chip credentialcard_chipsPENDING → 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​

Perso before or after activation — pick one

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​

System admin (Store admin) or platform key
POST /admin/physical-cards/print-orders
Idempotency-Key: <required>
FieldTypeRequiredDescription
countnumber (1–10000)✅How many cards to print
expires_atdate-time✅Expiry printed on the plastic; carried over to the card at activation
batch_idstring—Batch reference the bureau quotes back; auto-generated as PO-XXXXXXXXXX if omitted
design_codestring—Artwork the bureau should print
include_panboolean (default true)—Whether the artwork carries the full PAN
metadataobject—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.

Why the response does not return the file

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:

include_pan = true
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, recording exported_at / exported_by;
  • export_count is incremented and file_sha256 stores 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 }
Request
{
"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:

  1. 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. Pass order_id in the body to pin the import to one batch — rows from another batch then fail with wrong_order.
  2. chip_generation is 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.
  3. Best-effort per row, one transaction per row. A single bad row does not roll back the rest; the operator only resubmits the failed portion. A 10k-row file that aborts wholesale is not usable in operations. The endpoint is naturally idempotent (plastic already taken in lands in skipped), so no Idempotency-Key is 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.

Deprecated variant

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:

SelectorMeaning
stock_idsAn explicit list of cards
batch_idThe 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.

System admin / platform key
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:

FieldDescription
card_tokenBlock 4 — an opaque token, 16 bytes hex. Not the PAN
key_version, kdf_refHandles for the diversified key in the HSM — never the actual key
perso_epochThe perso timestamp covered by block 5; fixed at reservation time
issuer_block, expected_mac, counter_blockContents of block 5 / 6 / counter — Classic only
sector_keysCrypto1 keys + trailers for all 16 sectors — Classic only, null for DESFire
layoutWhere 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 keys

On 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​

Corporate admin
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:

FieldDescription
stock_id or chip_uidThe stock card to activate (one of the two)
applet_aidThe AID (hex) for chip personalization, e.g. F51840

Inside a single transaction, card-core:

  1. 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 return 404 — existence is never leaked through a 403.
  2. Creates the cards record with form_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.
  3. Binds the PHYSICAL chip credential. Plastic that was not pre-personalized → the credential mints its own token and sits at perso_state = PENDING. Plastic that was written at step 4b → the credential reuses the card_token + perso epoch already on the chip verbatim and goes straight to PERSONALIZED / 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 stays PENDING so a perso station can confirm later.
  4. Moves the card material onto the card record: stock goes to activated, and pan_token / pan_hash / encrypted_pan are 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.

Activation here ≠ a spendable card

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":

Cardholder
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.

No PIN

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 is CHIP_ISSUER_MASTER_KEY in 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 holds pan_token, PAN (if the artwork prints it), last4, expiry and design.
  • Because chip_uid is 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​

MechanismDetail
Storageencrypted_pan (ciphertext) + pan_hash for lookups + pan_hash_key_version for rotation
Single exit pathThe file export in step 2 — counted (export_count) and digested (file_sha256)
Never revealed againA printed PAN is never returned through the API; only a card-core-generated PAN is shown once, in the activation response
No number reuseRuined plastic keeps its row at spoiled; deleting the row would return the PAN to the generator
Range exhaustion guardMinting 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_generationNotes
DESFIRE_EV1, DESFIRE_EV2, DESFIRE_EV3EV2/EV3 enable proximity_check_enabled once PERSONALIZED
MIFARE_CLASSIC_1K, MIFARE_CLASSIC_EV1_1KThe Crypto1 path — only Classic exports keys via POST /chip/keys

interface_caps: CONTACTLESS (default), CONTACT, DUAL.

Not EMV

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 fileHas a pan columnNo PAN in the file
The plastic carriesPAN + last4 + QROnly the QR (pan_token) + last4
Card's PAN at activationReuses the printed PANcard-core generates an internal PAN
RiskThe PAN leaves the system with the fileThe 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:

ItemCurrent state
QA, SHIPPED, DELIVEREDAbsent from card_print_orders (only ordered / exported / canceled). QA and shipping currently sit outside card-core
Goods-receipt reconciliationThere 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 cardNo dedicated flow yet — today you revoke the credential and re-run print order → allocate → activate
PINDoes 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

EndpointPermissionIdempotency-Key
POST /admin/physical-cards/print-ordersSystem admin / platform✅
GET /admin/physical-cards/print-ordersSystem admin / platform—
GET /admin/physical-cards/print-orders/{order_id}System admin / platform—
GET /admin/physical-cards/print-orders/{order_id}/fileSystem admin / platform—
POST /admin/physical-cards/uid-importSystem admin / platform—
POST /admin/physical-cards/print-orders/{order_id}/uid-importSystem admin / platform— (deprecated)
POST /admin/physical-cards/allocateSystem admin / platform✅
POST /admin/physical-cards/perso/prepareSystem admin / platform (throttled 60/min)—
POST /admin/physical-cards/perso/confirmSystem admin / platform—
GET /admin/physical-cards · GET /admin/physical-cards/{stock_id}System admin / platform—
GET /physical-cardsCorporate admin (scoped to their corporate)—
POST /physical-cards/activateCorporate admin✅
POST /physical-cards/perso-returnCorporate admin / platform / system admin—
POST /cards/{card_id}/chip/perso/jobPlatform / system admin—
POST /cards/{card_id}/chip/perso/prepare · .../confirmPerso station / platform—
POST /chip/keysPerso station / platform (throttled 60/min)—
POST /my/cards/{card_id}/activateCardholder (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.