Skip to content

Client-side card encryption

Paygasus Pay accepts card data only in encrypted form. Your customer’s browser encrypts the card fields with a public key issued by Paygasus, and the resulting ciphertext travels through your server to our API as an opaque block. Neither your server nor Paygasus ever handles the cleartext card number — decryption happens inside the payment processor’s hardware security boundary.

This design keeps your servers out of the cardholder-data path: the only card-related material they touch is ciphertext, which significantly reduces your PCI DSS scope compared to posting raw card numbers to your backend.

  1. Your server creates a draft payment intent and requests an encryption key from Paygasus, using your secret key.
  2. Your server passes the public key material to the browser.
  3. The browser encrypts the card fields and returns the ciphertext to your server.
  4. Your server authorizes the intent, attaching the ciphertext as an encrypted_card source.

Only steps 1 and 4 call the Paygasus API, and both are authenticated with your sk_ credential from your server. The browser never talks to Paygasus directly.

Request a key from your server. This call requires your secret key, so never make it from the browser.

Terminal window
curl -X POST https://api.paygasus.com/payments/card/encryption-keys \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json"

A successful request returns 201 Created:

{
"id": "cek_01J9V2M3N4P5Q6R7S8T9V0W1X2",
"scheme": "RSA_OAEP_SHA256_V1",
"public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3bOOfW6F...",
"expires_at": 1783634100000
}
Field Type Description
id string Key identifier, prefixed cek_. Sent back with the ciphertext so Paygasus knows which key encrypted it.
scheme string The encryption procedure the client must follow. The scheme string fully determines the algorithm, padding, and payload layout described on this page.
public_key string Base64-encoded DER (SPKI) RSA public key, importable directly by the Web Crypto API.
expires_at integer Unix timestamp in milliseconds after which the key is no longer accepted. Request a fresh key for each checkout session; do not cache keys beyond expires_at.

Forward id, scheme, and public_key to the browser — none of these are secrets. Keep your sk_ credential and the raw API response on the server.

Under RSA_OAEP_SHA256_V1, the card fields are concatenated into a single UTF-8 string, in this exact order, with no delimiters:

Order Field Contents Example
1 number Card number (PAN), digits only 4005550000000019
2 name Name on card John Doe
3 exp_month Two-digit expiration month 01
4 exp_year Four-digit expiration year 2034
5 cvv Security code, 3–4 digits 123

For the example values above, the plaintext block is:

4005550000000019John Doe012034123

Because the fields are variable-length and there are no delimiters, you must also record the byte length of each field (the UTF-8 encoded length, which matters for names containing non-ASCII characters). This layout travels alongside the ciphertext and tells the processor how to slice the decrypted block back into fields:

{ "number": 16, "name": 8, "exp_month": 2, "exp_year": 4, "cvv": 3 }

RSA_OAEP_SHA256_V1 is RSA-OAEP with SHA-256 as both the OAEP hash and MGF1 hash, applied directly to the plaintext block, with the result Base64-encoded. The Web Crypto API supports this natively:

/**
* Encrypt card data for Paygasus Pay under RSA_OAEP_SHA256_V1.
*
* @param {Object} key - { id, scheme, public_key } from your server
* @param {Object} card - { number, name, exp_month, exp_year, cvv }
* @returns {Object} an `encrypted_card` source, ready to send to your server
*/
async function encryptCard(key, card) {
if (key.scheme !== "RSA_OAEP_SHA256_V1") {
throw new Error(`Unsupported encryption scheme: ${key.scheme}`);
}
// The field order is fixed by the scheme. Deriving both the plaintext
// and the layout from this one array guarantees they cannot disagree.
const FIELDS = ["number", "name", "exp_month", "exp_year", "cvv"];
const enc = new TextEncoder();
const plaintext = FIELDS.map((f) => card[f]).join("");
const layout = Object.fromEntries(
FIELDS.map((f) => [f, enc.encode(card[f]).length])
);
// public_key is base64 DER (SPKI)
const der = Uint8Array.from(atob(key.public_key), (c) => c.charCodeAt(0));
const publicKey = await crypto.subtle.importKey(
"spki",
der,
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["encrypt"]
);
const ciphertext = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
publicKey,
enc.encode(plaintext)
);
return {
type: "encrypted_card",
key_id: key.id,
encrypted_block: btoa(String.fromCharCode(...new Uint8Array(ciphertext))),
layout,
};
}

Send the returned object to your server over TLS and discard the card fields immediately. Do not log, persist, or echo the cleartext values anywhere in your client code.

Create a draft intent when the checkout begins — typically alongside the key request, when your payment form loads:

Terminal window
curl -X POST https://api.paygasus.com/payments/card \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2c8d5a1e-9b0f-4c3a-8e7d-6f5a4b3c2d1e" \
-d '{
"amount": 1204,
"currency": "USD"
}'

The response is a draft intent — note payment_method is null until authorization succeeds:

{
"id": "pi_card_ZXAG01KX6SPVWV8H50WXDZPDX2CS8R",
"amount": 1204,
"amount_captured": 0,
"amount_refunded": 0,
"currency": "USD",
"status": "created",
"status_at": null,
"processor_ref": null,
"failure_reason": null,
"cancellation_reason": null,
"capture_method": "automatic",
"payment_method": null,
"description": null,
"statement_descriptor": null,
"metadata": {},
"created": 1783713460123,
"updated": 1783713460123,
"livemode": false
}

When the browser returns the ciphertext, authorize the intent with the encrypted_card source. Do this promptly — encryption keys are short-lived, and the encrypted block is only accepted while its key is valid:

Terminal window
curl -X POST https://api.paygasus.com/payments/card/{id}/authorize \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f14e45f-ceea-467f-a0e6-d5c6e2f0a1b3" \
-d '{
"source": {
"type": "encrypted_card",
"key_id": "cek_01J9V2M3N4P5Q6R7S8T9V0W1X2",
"encrypted_block": "s3ZmiL1SSZC8QyBpj/Wn+VwpLDgp41IwstEHQS8u4EQJ...",
"layout": { "number": 16, "name": 8, "exp_month": 2, "exp_year": 4, "cvv": 3 }
}
}'

Paygasus forwards the encrypted block to the processor during authorization and does not store it. With capture_method: "automatic" (the default), a successful authorization proceeds directly to committed; the response now carries the card summary, built from the processor’s response:

{
"id": "pi_card_ZXAG01KX6SPVWV8H50WXDZPDX2CS8R",
"amount": 1204,
"amount_captured": 1204,
"amount_refunded": 0,
"currency": "USD",
"status": "committed",
"status_at": 1783713529456,
"processor_ref": "ch_0k2f8a91b3",
"failure_reason": null,
"cancellation_reason": null,
"capture_method": "automatic",
"payment_method": { "brand": "visa", "last4": "0019" },
"description": null,
"statement_descriptor": null,
"metadata": {},
"created": 1783713460123,
"updated": 1783713529456,
"livemode": false
}

With capture_method: "manual", the intent rests at authorized with amount_captured: 0 until you commit it — see Authorize and commit for the two-step flow.

Code Meaning What to do
encryption_key_expired The key_id refers to a key past its expires_at. Request a new key and re-encrypt. Since re-encryption requires the cleartext card data, handle this by restarting the checkout form, not by retrying server-side.
unknown_encryption_key The key_id does not exist or does not belong to your account. Verify you are forwarding the id from the key response unmodified.
invalid_encryption_block The ciphertext could not be decrypted, or the layout did not match the decrypted contents. Almost always a layout/concatenation mismatch or the wrong public key. Confirm field order and byte counts.

The public key is not a secret — it is safe to embed in a page or ship to a browser. The security of the flow rests on the corresponding private key, which never leaves the processor’s boundary, and on TLS protecting the ciphertext in transit.

Treat each key as belonging to a single checkout session. Request a key when the payment form loads, encrypt once, and let it expire.