Sensitive data
A small subset of card endpoints deal with cardholder credentials and authentication material. These endpoints share the same per-partner scoping as every other endpoint on the API (a foreign card returns 404) and are also recorded for audit purposes — PayNovus retains a record of every sensitive call your partner makes.
What counts as sensitive
The following operations are classified as sensitive:
| Operation | Why it's sensitive |
|---|---|
| Retrieve PAN (card number) | Full Primary Account Number. Sufficient for online purchases. |
| Retrieve CVV | The 3-digit security code printed on the card. Required for any card-not-present transaction. |
| Retrieve 3DS phone number | The phone number used to deliver OTP for 3-D Secure. |
| Set 3DS answer | The outcome of a 3-D Secure challenge, written back to the issuer. |
| Change PIN | Replaces the cardholder's PIN. |
| Force PIN lock | Invalidates the current PIN after too many wrong attempts. |
| Reset PIN tries | Resets the wrong-attempt counter on a temporarily locked PIN. |
| Holder authorize | Confirms a sensitive operation by re-prompting the cardholder for their PIN. |
Why the extra friction
These operations can be used to:
- Clone a card (PAN + CVV + expiry).
- Bypass 3-D Secure (if the OTP phone is changed).
- Lock out a cardholder (force PIN lock).
- Approve a fraudulent operation (holder-authorize without the cardholder's consent).
Because of that, every sensitive call is:
- Scoped to your partner (same per-partner scoping as every other endpoint — a foreign card returns 404).
- Rate-limited more aggressively than non-sensitive endpoints.
- Encrypted at the partner boundary for PAN and CVV responses.
- Subject to webhook notifications — the cardholder receives an SMS / push on every successful sensitive call.
The expDate rule
Every sensitive endpoint requires the card's expiration date (MM/YYYY) in the request body or query string. This is a second factor that prevents a stolen API key alone from being enough to perform a sensitive operation.
See the Cards overview → expDate for the exact format and why the field exists.
Encryption for sensitive responses
PAN and CVV endpoints never return plaintext pan, cardNumber, or cvv fields. They return an encrypted envelope that only your partner can decrypt with the private key you registered with Paynovus (see Authentication → PAN and CVV encryption key).
The envelope uses your partner's P-521 encryption public key:
ECDH-ES(secp521r1) → HKDF-SHA256 → AES-256-GCM
The private encryption key stays with the partner. It is never sent in the
request and is never stored by Paynovus. The encryption public key is stored in
the Partner record under encryption and is selected by its keyId.
What the partner receives
Every successful PAN / CVV response has the same shape — eight short fields, ~280 bytes total. All values are base64url encoded unless noted otherwise.
{
"v": 1,
"p": "cvv2",
"c": "KDR0020100128181",
"k": "partner-encryption-2026-01",
"e": "AwBaSUQFOnw73JF50jtV6HafTUp988v6tpKu5yxPUR5Sory-Va3aWNhW7K7yJSkDNyZVSBkVB73F-kQSA619e7sMdA",
"iv": "_PnfdEKxQZlGdzT7",
"ct": "D0Pw",
"t": "aeL4wil09Wj8Yx7T1Ffzgw"
}
| Field | Description |
|---|---|
v | Envelope version, currently 1. |
p | Purpose — cvv2 or pan. Bound into the AES-GCM authenticated context. |
c | cardKey the value belongs to. Bound into the AAD so a ciphertext from card A cannot be replayed against card B. |
k | Partner encryption keyId. Bound into the AAD so the partner always uses the right private key. |
e | Ephemeral server P-521 public key, compressed SEC1 (67 bytes → ~90 chars base64url). A fresh key is generated per response (forward secrecy). |
iv | 12-byte AES-GCM nonce, base64url. |
ct | AES-256-GCM ciphertext, base64url (no padding). |
t | AES-GCM authentication tag, base64url. |
How the partner decrypts
The partner rebuilds the AAD as v|p|c|k and combines e with their static
P-521 private key via ECDH. The shared secret feeds HKDF-SHA256 to derive a
fresh 32-byte AES-256-GCM key, which then opens the envelope.
import crypto from 'node:crypto';
import { readFileSync } from 'node:fs';
/**
* Decrypt a partner encrypted card-data envelope using the partner's
* static P-521 private key.
*
* @param {object} envelope the `data` field of a CVV / PAN response
* @param {string|Buffer} privateKey PEM file path OR a pre-loaded KeyObject
* @returns {{ purpose: 'cvv2'|'pan', value: string }}
*/
export function decryptPartnerCardData(envelope, privateKey) {
if (envelope.v !== 1) throw new Error('Unsupported envelope version');
if (envelope.p !== 'cvv2' && envelope.p !== 'pan') {
throw new Error('Unsupported purpose');
}
// 1. Load the partner's static P-521 private key into a crypto.ECDH instance.
const keyObj =
typeof privateKey === 'string' ? crypto.createPrivateKey(readFileSync(privateKey, 'utf8')) : privateKey;
const partnerECDH = crypto.createECDH('secp521r1');
partnerECDH.setPrivateKey(Buffer.from(keyObj.export({ format: 'jwk' }).d, 'base64url'));
// 2. ECDH against the server's compressed ephemeral public key.
const sharedSecret = partnerECDH.computeSecret(Buffer.from(envelope.e, 'base64url'));
// 3. Derive the 32-byte AES-GCM content key with HKDF-SHA256.
// AAD = `v|p|c|k` — partner rebuilds it from the envelope.
const aad = Buffer.from(`${envelope.v}|${envelope.p}|${envelope.c}|${envelope.k}`, 'utf8');
const contentKey = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, Buffer.alloc(0), aad, 32));
// 4. Decrypt and verify the AES-GCM tag.
const decipher = crypto.createDecipheriv('aes-256-gcm', contentKey, Buffer.from(envelope.iv, 'base64url'));
decipher.setAAD(aad);
decipher.setAuthTag(Buffer.from(envelope.t, 'base64url'));
const value = Buffer.concat([decipher.update(Buffer.from(envelope.ct, 'base64url')), decipher.final()]).toString(
'utf8'
);
return { purpose: envelope.p, value };
}
// Example:
const response = await fetch(
'https://api.card-management-staging.paynovus.com/api/partner/cards/KDR0020100128181/card-cvv',
{ headers: { Authorization: `Bearer ${process.env.PARTNER_JWT}` } }
).then((r) => r.json());
const { purpose, value } = decryptPartnerCardData(response.data, process.env.PARTNER_ENCRYPTION_PRIVATE_KEY_PEM);
Why this envelope shape
- Forward secrecy. A fresh ephemeral key per response means a leaked static private key compromises only future traffic, not historical ciphertexts.
- Strong identity binding. The ephemeral private key never leaves the Paynovus server, so only Paynovus could have produced the ciphertext.
- Smallest possible payload. The four short binding fields (
v,p,c,k) replace the largeraadblob, and the ephemeral public key is shipped as 67-byte compressed SEC1 (~90 chars base64url) instead of a ~268-char PEM. - Tamper detection. Any modification of the ciphertext, IV, tag, purpose, cardKey, or keyId makes the AES-GCM authentication tag fail.
Operational rules
- The private key is never sent to Paynovus.
- Do not log, persist, or forward the decrypted
value— use it only for the protected operation that requires it. - For local development, the CIP-Backend repo ships a script at
scripts/decrypt-partner-envelope.tsyou can run withnpm run dec-partner-payloadafter pasting the envelope into theSAMPLE_ENVELOPEconstant near the top of the file.
See Authentication → PAN and CVV encryption key for key generation, and the Get card CVV API reference or Get card number API reference for the response schema.
Audit trail
Every sensitive operation produces an entry in the partner's audit log (see GET /api/partner/individual/accounts/{id}/logs/audit and the equivalent card-level history). The entry contains:
- The endpoint that was called.
- The partner identity and JWT subject.
- The card identifier (masked PAN).
- The HTTP status code returned.
- The timestamp.
API Reference
See the Cards section in the API Reference → for the full list of endpoints, payloads, and the Try it widget.