Skip to main content

Server-to-Server (S2S) card payments

Server-to-Server integration lets you process card payments (Visa, Mastercard) directly on your website without redirecting the customer to an external payment gateway. Card data is encrypted with an RSA public key to secure the transaction.

PCI DSS certification required

S2S integration requires PCI DSS certification because payment card data is processed by your infrastructure. Enabling this integration mode requires prior approval from dpay. Contact us before you begin implementation.

Learn more about the requirements on the PCI DSS information for merchants page.

How it works

Foreign cards and DCC

If the cardholder pays with a foreign card and Dynamic Currency Conversion is enabled for your service, the /pay/card-otp response may include a currency conversion offer (redirectType: "DCC_OFFER"). See Dynamic Currency Conversion (DCC) for the complete flow.

GET/api/v1_0/cards/public-keyRetrieve the RSA public key used to encrypt card data.Full contract in the API Reference POST/api/v1_0/cards/payment/{transactionId}/pay/card-otpSubmit encrypted card data and handle 3D Secure.Full contract in the API Reference

Step 1: Register the payment

Register the transaction in the same way as in the standard integration:

curl -X POST https://api-payments.dpay.pl/api/v1_0/payments/register \
-H "Content-Type: application/json" \
-d '{
"transactionType": "transfers",
"service": "abc123",
"value": "99.99",
"url_success": "https://myshop.example/success",
"url_fail": "https://myshop.example/error",
"url_ipn": "https://myshop.example/api/ipn",
"checksum": "..."
}'

Save the transactionId from the response. You will need it in the next steps.

Step 2: Retrieve the RSA public key

Retrieve the public key used to encrypt the card data:

GET https://api-payments.dpay.pl/api/v1_0/cards/public-key

Response

The endpoint returns the key as raw PEM text (not JSON):

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
-----END PUBLIC KEY-----

You can pass the response body directly to the encryption library. Do not parse it as JSON.

Cache the public key

The public key does not change often. You may cache it on the server, but check for updates every 24 hours.

Step 3: Prepare and encrypt the card data

Card data JSON structure

Prepare a JSON object with the card data in the following format:

{
"PN": "4111111111111111",
"SC": "123",
"DT": "12/25",
"ID": "abc-def-123-456",
"TX": 1700000000
}
FieldDescriptionFormat
PNCard number (PAN)Digits without spaces
SCCVV/CVC code3 digits (4 for Amex)
DTExpiry dateMM/YY
IDTransaction identifiertransactionId from Step 1
TXUnix timestampCurrent time in seconds

RSA/PKCS#1 encryption

Card data must be encrypted using RSA with PKCS#1 v1.5 padding and then encoded in Base64.

TypeScript (using JSEncrypt)

import JSEncrypt from 'jsencrypt';

function encryptCardData(
cardNumber: string,
cvv: string,
expiryDate: string,
transactionId: string,
publicKey: string
): string {
const cardData = JSON.stringify({
PN: cardNumber,
SC: cvv,
DT: expiryDate,
ID: transactionId,
TX: Math.floor(Date.now() / 1000),
});

const encrypt = new JSEncrypt();
encrypt.setPublicKey(publicKey);

const encrypted = encrypt.encrypt(cardData);
if (!encrypted) {
throw new Error('Card data encryption failed');
}

return encrypted; // Already Base64-encoded
}

PHP (OpenSSL)

function encryptCardData(
string $cardNumber,
string $cvv,
string $expiryDate,
string $transactionId,
string $publicKeyPem
): string {
$cardData = json_encode([
'PN' => $cardNumber,
'SC' => $cvv,
'DT' => $expiryDate,
'ID' => $transactionId,
'TX' => time(),
]);

$publicKey = openssl_pkey_get_public($publicKeyPem);
if (!$publicKey) {
throw new Exception('Invalid public key');
}

$encrypted = '';
$result = openssl_public_encrypt($cardData, $encrypted, $publicKey, OPENSSL_PKCS1_PADDING);
if (!$result) {
throw new Exception('Encryption failed');
}

return base64_encode($encrypted);
}

Python

from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
import json
import base64
import time

def encrypt_card_data(card_number, cvv, expiry_date, transaction_id, public_key_pem):
card_data = json.dumps({
'PN': card_number,
'SC': cvv,
'DT': expiry_date,
'ID': transaction_id,
'TX': int(time.time()),
})

key = RSA.import_key(public_key_pem)
cipher = PKCS1_v1_5.new(key)
encrypted = cipher.encrypt(card_data.encode('utf-8'))

return base64.b64encode(encrypted).decode('utf-8')

Step 4: Submit the card payment

Send the encrypted card data to the card payment endpoint:

POST https://api-payments.dpay.pl/api/v1_0/cards/payment/{transactionId}/pay/card-otp
Content-Type: application/json

Request parameters

{
"encryptedCardData": "Base64-encoded-encrypted-data...",
"deviceInfo": {
"browserAcceptHeader": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"browserJavaEnabled": "false",
"browserLanguage": "pl-PL",
"browserColorDepth": "24",
"browserScreenHeight": "1080",
"browserScreenWidth": "1920",
"browserTZ": "-60",
"browserUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)...",
"systemFamily": "Windows",
"geoLocalization": "52.2297,21.0122",
"deviceID": "device-unique-id",
"applicationName": "Chrome"
}
}

Optional parameters (for subsequent calls)

FieldTypeWhen to send it
threeDsConfirmedbooleantrue - after the 3DS challenge is completed in the customer's browser. Send the same encryptedCardData again.
dccDecision"accept" / "reject"The customer's decision after a DCC offer. Send the same encryptedCardData again. See DCC.

DeviceInfo object

DeviceInfo is required for the 3D Secure verification process. Every field except browserJavaEnabled is required. If any required field is missing, the endpoint returns HTTP 422:

FieldTypeRequiredDescription
browserAcceptHeaderstringYesBrowser Accept header
browserJavaEnabledstringNo"true" / "false" - whether Java is enabled in the browser
browserLanguagestringYesBrowser language (for example, "pl-PL")
browserColorDepthstringYesScreen color depth
browserScreenHeightstringYesScreen height in pixels
browserScreenWidthstringYesScreen width in pixels
browserTZstringYesTime zone offset in minutes (for example, "-60" for CET)
browserUserAgentstringYesFull browser User-Agent
systemFamilystringYesOperating system family (for example, "Windows", "iOS")
geoLocalizationstringYesDevice location (for example, coordinates from the Geolocation API or "unknown")
deviceIDstringYesDevice identifier (maximum 64 characters); the deviceId alias is also accepted
applicationNamestringYesApplication/browser name (maximum 64 characters)

Collecting DeviceInfo (JavaScript)

function getDeviceInfo() {
return {
browserAcceptHeader: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
browserJavaEnabled: navigator.javaEnabled?.() ? 'true' : 'false',
browserLanguage: navigator.language || 'pl-PL',
browserColorDepth: String(screen.colorDepth),
browserScreenHeight: String(screen.height),
browserScreenWidth: String(screen.width),
browserTZ: String(new Date().getTimezoneOffset()),
browserUserAgent: navigator.userAgent,
systemFamily: navigator.platform || 'Unknown',
geoLocalization: 'unknown', // For example, coordinates from the Geolocation API when the customer consents
deviceID: 'device-unique-id', // Your own stable device identifier
applicationName: 'Chrome',
};
}

Step 5: Handle the response

The /pay/card-otp endpoint response contains message.redirectType, which determines the next frontend action:

redirectTypeContent fieldMeaning
SUCCESS(empty)Payment posted - redirect the customer to the success page
FORMredirectText (Base64 HTML)3D Secure authorization required - render the form
URLredirectText (URL)Redirect to an external URL
DCC_OFFERdccOffer (object)Foreign card - present the currency conversion offer. See DCC

Handling 3D Secure (FORM)

If 3D Secure authorization is required, message.redirectText contains a complete Base64-encoded HTML form that submits automatically after it is inserted into the page:

3DS response

{
"success": true,
"status": "success",
"message": {
"redirectText": "<Base64 HTML with an auto-submit form>",
"redirectType": "FORM",
"dccOffer": null
}
}

Handling the FORM redirect

When message.redirectType is FORM, decode message.redirectText from Base64 and insert the resulting HTML into the page. The decoded content includes a threeDsForm form with hidden fields and a script that submits it automatically. A script inserted through innerHTML does not execute, so submit the form manually after insertion:

function handle3DS(response) {
const { redirectType, redirectText } = response.message;

if (redirectType === 'FORM') {
const html = atob(redirectText);

const container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);

// The auto-submit script in redirectText does not execute when inserted
// through innerHTML, so submit the form manually.
document.getElementById('threeDsForm').submit();
}
}

After the 3DS process is complete, the customer is redirected to url_success or url_fail, and dpay.pl sends an IPN notification.

Returning from 3DS

If your integration handles the return from a challenge without reloading the page, call /pay/card-otp again with threeDsConfirmed: true and the same encryptedCardData. The gateway finalizes the payment and returns SUCCESS.

Successful response (without 3DS)

If 3DS is not required, the payment is processed immediately:

{
"success": true,
"status": "success",
"message": {
"redirectText": "",
"redirectType": "SUCCESS",
"dccOffer": null
}
}

The message.dccOffer field is always present and is null when no currency conversion offer applies.

Errors and encrypted-data validity window

When processing fails, the endpoint returns HTTP 200 with success: false, and message is a text message:

{
"success": false,
"status": "error",
"message": "E104B Invalid card data"
}

The E101-E104 family of codes concerns the encrypted card payload:

CodeCause
E101 Invalid card dataThe ID field in the encrypted data does not match the transaction identifier in the URL
E103 Invalid card dataThe encrypted payload is incomplete (one of PN, SC, DT, ID, or TX is missing)
E104A Invalid card dataThe TX timestamp differs from server time by more than 3600 seconds
E104B Invalid card dataA payload older than 600 seconds was used without threeDsConfirmed: true
Payload validity window

Encrypted card data remains valid for no more than 600 seconds from the time it is generated (TX). For a repeat request with threeDsConfirmed: true (the return from a 3DS challenge), the window extends to 3600 seconds. Generate encryptedCardData immediately before submitting the payment.

Payment processing messages

When an error concerns payment processing rather than the card payload, message contains one of the messages below. We recommend showing it to the user (or displaying its equivalent in your language) and taking the recommended action:

messageMeaningRecommended action
Payment was declined by the card issuer.The card issuer rejected the transaction (insufficient funds, limit, or block).Ask for another card or advise the customer to contact their bank. Do not retry with the same card.
Invalid card details.The card number or card type is invalid or unsupported.Ask the customer to correct the card details.
Card already registered.The card is already saved for this service (applies when saving a card for recurring payments).Use the existing card alias instead of saving it again.
Card could not be saved. Please try again.Saving the card (tokenization) failed.Retry saving the card.
Card is not available for recurring charges. Please re-add the card.The saved card does not have a valid recurring-charge reference.Save the card again to create a new alias.
Capture was declined by the card issuer.The issuer rejected capture of the preauthorization.Contact dpay support and check whether the preauthorization has expired.
Cancellation was declined by the card issuer.Cancellation of the preauthorization was rejected.Contact dpay support.
Refund is not available for this transaction.A refund is not available for this transaction (for example, it is unpaid or the refund window has passed).Check the transaction status and use check-refund-availability.
The requested transaction was not found.The specified transaction or reference does not exist.Verify the transaction identifier.
Payment service is temporarily unavailable. Please try again.A temporary issue occurred with the payment operator.Retry after a short delay (transient error).
The payment could not be processed.The payment could not be completed for another reason.Retry or ask the customer to use another payment method.
Invalid request data.The request contains invalid data.Check the request parameters.
Payment configuration error.The payment service is misconfigured.Contact dpay support.
Matching messages

An error response contains a text message field. New messages may be added to the list above. Make your logic resilient to unknown values by treating them like The payment could not be processed., instead of relying only on exact matches for known strings.

Complete Node.js example

const crypto = require('crypto');
const axios = require('axios');
const JSEncrypt = require('jsencrypt');

async function processCardPayment(orderData, cardData, deviceInfo) {
const service = process.env.DPAY_SERVICE;
const secretHash = process.env.DPAY_SECRET_HASH;

// Step 1: Register the payment
const checksum = crypto
.createHash('sha256')
.update(`${service}|${secretHash}|${orderData.value}|${orderData.urlSuccess}|${orderData.urlFail}|${orderData.urlIpn}`)
.digest('hex');

const registerResponse = await axios.post(
'https://api-payments.dpay.pl/api/v1_0/payments/register',
{
transactionType: 'transfers',
service,
value: orderData.value,
url_success: orderData.urlSuccess,
url_fail: orderData.urlFail,
url_ipn: orderData.urlIpn,
checksum,
}
);

const { transactionId } = registerResponse.data;

// Step 2: Retrieve the public key (the response is raw PEM, not JSON)
const keyResponse = await axios.get(
'https://api-payments.dpay.pl/api/v1_0/cards/public-key',
{ responseType: 'text' }
);

// Step 3: Encrypt the card data
const encrypt = new JSEncrypt();
encrypt.setPublicKey(keyResponse.data);

const encryptedCardData = encrypt.encrypt(JSON.stringify({
PN: cardData.number,
SC: cardData.cvv,
DT: cardData.expiry,
ID: transactionId,
TX: Math.floor(Date.now() / 1000),
}));

// Step 4: Submit the payment
const payResponse = await axios.post(
`https://api-payments.dpay.pl/api/v1_0/cards/payment/${transactionId}/pay/card-otp`,
{
encryptedCardData,
deviceInfo,
}
);

return payResponse.data;
}

Testing in sandbox mode

When test mode is enabled for the service, the simulation scenario is selected by the card number (PAN) included in encryptedCardData sent to /pay/card-otp. You follow the complete flow (registration, key retrieval, encryption, and payment). The only difference is that the request is not sent to the card processor and the result is simulated:

Card numberScenario
4111111111111111Visa - payment completed successfully
5555555555554444Mastercard - payment completed successfully
4012888888881881Visa - success with a 3DS challenge (FORMSUCCESS cascade)
4000000000000002Visa - declined
4000000000000069Visa - expired card
4000000000000127Visa - insufficient funds
4000000000000085Visa - timeout
5105105105105100Mastercard - declined
5346930000008110Mastercard - DCC offer (EUR→PLN, valid for 30 minutes)
5346930000008128Mastercard - card not eligible for DCC, transparent SUCCESS
5346930000008136Mastercard - DCC offer valid for 60 seconds (expiry test)
5346930000008144Mastercard - DCC + 3DS after the decision (DCC_OFFERFORMSUCCESS cascade)
info

Simulation works only when test mode is enabled for the service. Put the test number in the PN field of the encrypted payload, exactly as you would provide a real card number in production. The 3DS challenge scenario (4012888888881881) returns FORM with a test authorization page; after it completes, call /pay/card-otp again with threeDsConfirmed: true.

The full list of test numbers and details is available on the Test environment page.

Security

warning
  • Never store card data (numbers, CVVs, or expiry dates) on your server
  • Never log card data
  • RSA encryption must happen on the client side (in the browser), so raw card data never reaches your server
  • Use HTTPS throughout your site