Skip to main content

Card secrets & PIN

Contract §5. Section numbers are contract-wide and never change; the contract overview maps every section to its page.

Conventions — authentication, the response envelope, idempotency, retry safety, pagination, rate limits — are defined in the Partner Integration Guide and apply to every endpoint here.

The principle: your backend creates the encryption session; encrypted card data transits the service opaque; it never holds a key that could decrypt it. Plaintext PAN/CVV/PIN exist only inside the issuer and inside your backend — your PCI scope, your choice.

your backend the service (relay only) issuer
───────────── ───────────────── ──────
secret = 32 hex chars (16 bytes)
sessionId = RSA-OAEP(pubkey, base64(secret))
│ POST …/secrets {sessionId} forwards opaque ─▶ encrypts fields
◀─ { encryptedPan, encryptedCvc } relays opaque ◀─ under YOUR secret
decrypt with secret — plaintext never existed at the service

Cipher parameters

Secret32 hexadecimal characters, hex-decoded to 16 bytes. Not 32 bytes — decode the hex.
Session wrapRSA-OAEP, SHA-1 hash, over base64(secret-bytes); the result base64-encoded is the sessionId
Public keyPer environment — sandbox and production keys are different. Ask the service for the key matching your environment.
Field cipherAES-128-GCM — the key length follows from the 16-byte secret
ivbase64, per field. Each field carries its own.
database64 ciphertext with the 16-byte GCM authentication tag appended. Split it off before decrypting, and verify it — an unverified tag means an unauthenticated PAN.
PIN plaintextISO 9564-1 Format 2 PIN block, not a bare PIN
Session lifetimeSingle use. Create one per reveal.

Secrets & PIN endpoints

EndpointPurpose
POST /cards/:id/secretsbody { "sessionId": "<base64>" }200 { "encryptedPan": {"iv","data"}, "encryptedCvc": {"iv","data"}, "expiryMonth": "08", "expiryYear": "2030" } — the two expiry fields are null in the rare case the card read omits them
POST /cards/:id/pin/revealbody { "sessionId": "…" }200 { "encryptedPin": {"iv","data"} }
PUT /cards/:id/pinbody { "sessionId": "…", "encryptedPin": {"iv","data"} }200 { "updated": true }

POST for the two reveals, even though they read. A sessionId wraps the key that decrypts a PAN. On a GET it would travel in a header or a query string, and both land in access logs, intermediary caches and browser history — places nobody audits and nobody purges. A request body does not. Nothing is created, so both answer 200.

expiryMonth/expiryYear come back in CLEAR, and they are a merge. The issuer's secrets call returns only the two encrypted fields; the expiry is read from the card and merged in, so you can render a card from one response. It is the same value GET /cards/:id returns.

Secrets handling rules

  • Sessions are single-use and short-lived — create one per reveal.
  • Never log sessionId or any encrypted field. the service does not: those four names are on its masking list, and the issuer client keeps them out of its log arguments entirely rather than relying on masking alone.
  • Decrypt in memory, render, discard. Never store a decrypted value.
  • Reveals are rate-limited per card: 10 per 60 seconds429 REVEAL_RATE_LIMITED. PUT /cards/:id/pin is not subject to it: throttling your ability to change a compromised PIN is the wrong failure to protect against. The ceiling is an abuse control, not an access control — if the counter store is unavailable it fails open and the reveal proceeds, so do not design as though it were unbreachable. Ownership and the canceled-card refusal never fail open; they depend on nothing that can be unavailable.
  • A canceled card answers 409 CARD_TERMINAL on all three. Its PAN still identifies a real account, so it is refused rather than relayed.
  • A rejected sessionId is 422 SESSION_INVALID, and there is no plaintext fallback anywhere — a bad session yields nothing, never a clear PAN.
  • A timed-out PIN write is 502 PIN_WRITE_UNCONFIRMED, not a generic outage: the PIN may or may not have changed. Read it back before writing again.