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
| Secret | 32 hexadecimal characters, hex-decoded to 16 bytes. Not 32 bytes — decode the hex. |
| Session wrap | RSA-OAEP, SHA-1 hash, over base64(secret-bytes); the result base64-encoded is the sessionId |
| Public key | Per environment — sandbox and production keys are different. Ask the service for the key matching your environment. |
| Field cipher | AES-128-GCM — the key length follows from the 16-byte secret |
iv | base64, per field. Each field carries its own. |
data | base64 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 plaintext | ISO 9564-1 Format 2 PIN block, not a bare PIN |
| Session lifetime | Single use. Create one per reveal. |
Secrets & PIN endpoints
| Endpoint | Purpose |
|---|---|
POST /cards/:id/secrets | body { "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/reveal | body { "sessionId": "…" } → 200 { "encryptedPin": {"iv","data"} } |
PUT /cards/:id/pin | body { "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
sessionIdor 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 seconds —
429 REVEAL_RATE_LIMITED.PUT /cards/:id/pinis 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_TERMINALon all three. Its PAN still identifies a real account, so it is refused rather than relayed. - A rejected
sessionIdis422 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.