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.
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
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.
/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.
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
}
| Field | Description | Format |
|---|---|---|
PN | Card number (PAN) | Digits without spaces |
SC | CVV/CVC code | 3 digits (4 for Amex) |
DT | Expiry date | MM/YY |
ID | Transaction identifier | transactionId from Step 1 |
TX | Unix timestamp | Current 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)
| Field | Type | When to send it |
|---|---|---|
threeDsConfirmed | boolean | true - 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:
| Field | Type | Required | Description |
|---|---|---|---|
browserAcceptHeader | string | Yes | Browser Accept header |
browserJavaEnabled | string | No | "true" / "false" - whether Java is enabled in the browser |
browserLanguage | string | Yes | Browser language (for example, "pl-PL") |
browserColorDepth | string | Yes | Screen color depth |
browserScreenHeight | string | Yes | Screen height in pixels |
browserScreenWidth | string | Yes | Screen width in pixels |
browserTZ | string | Yes | Time zone offset in minutes (for example, "-60" for CET) |
browserUserAgent | string | Yes | Full browser User-Agent |
systemFamily | string | Yes | Operating system family (for example, "Windows", "iOS") |
geoLocalization | string | Yes | Device location (for example, coordinates from the Geolocation API or "unknown") |
deviceID | string | Yes | Device identifier (maximum 64 characters); the deviceId alias is also accepted |
applicationName | string | Yes | Application/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:
redirectType | Content field | Meaning |
|---|---|---|
SUCCESS | (empty) | Payment posted - redirect the customer to the success page |
FORM | redirectText (Base64 HTML) | 3D Secure authorization required - render the form |
URL | redirectText (URL) | Redirect to an external URL |
DCC_OFFER | dccOffer (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.
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:
| Code | Cause |
|---|---|
E101 Invalid card data | The ID field in the encrypted data does not match the transaction identifier in the URL |
E103 Invalid card data | The encrypted payload is incomplete (one of PN, SC, DT, ID, or TX is missing) |
E104A Invalid card data | The TX timestamp differs from server time by more than 3600 seconds |
E104B Invalid card data | A payload older than 600 seconds was used without threeDsConfirmed: true |
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:
message | Meaning | Recommended 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. |
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 number | Scenario |
|---|---|
4111111111111111 | Visa - payment completed successfully |
5555555555554444 | Mastercard - payment completed successfully |
4012888888881881 | Visa - success with a 3DS challenge (FORM → SUCCESS cascade) |
4000000000000002 | Visa - declined |
4000000000000069 | Visa - expired card |
4000000000000127 | Visa - insufficient funds |
4000000000000085 | Visa - timeout |
5105105105105100 | Mastercard - declined |
5346930000008110 | Mastercard - DCC offer (EUR→PLN, valid for 30 minutes) |
5346930000008128 | Mastercard - card not eligible for DCC, transparent SUCCESS |
5346930000008136 | Mastercard - DCC offer valid for 60 seconds (expiry test) |
5346930000008144 | Mastercard - DCC + 3DS after the decision (DCC_OFFER → FORM → SUCCESS cascade) |
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
- 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