Accept wallets#
Take Apple Pay and Google Pay through the same server-to-server endpoint you already use, without card data touching your server.
A wallet payment is a SALE with two extra fields and all the card fields removed. The wallet supplies an encrypted payment token, you pass it through, and the platform does the rest.
Every example below uses the same sample credentials so the hashes are reproducible: payer_email of [email protected] and a PASSWORD of SANDBOX_PASSWORD. Payload values are illustrative, but every field name, its presence or absence, and every hash is exact.
What changes, and what does not#
Three things change against a card sale.
You send digital_wallet, either applepay or googlepay. You send payment_token, the wallet's own token. And you omit every card field.
The signature changes too, and this is where wallet integrations usually fail.
Formula 8 is the whole signature#
A wallet SALE signs with Formula 8, which is email and password only.
md5(strtoupper( strrev(email) . PASSWORD ))
No card fragment, because there is no card. No trans_id, because the transaction does not exist yet. For our sample inputs that is:
5a3ad716b1b70d6e0a8d5f06548cf7ea
Because the card term is gone, this digest is the same for every wallet sale made by the same customer with the same password. That is expected, and it is not a weakness in your integration. Signing a wallet sale with Formula 1 instead produces an authentication failure that points nowhere near the cause.
The request#
https://{PAYMENT_URL}/postcurl -X POST https://{PAYMENT_URL}/post \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=SALE" \
-d "client_key={CLIENT_KEY}" \
-d "order_id=WAL-3001" \
-d "order_amount=120000" \
-d "order_currency=UZS" \
-d "order_description=Order WAL-3001" \
-d "digital_wallet=googlepay" \
-d "payment_token={WALLET_PAYMENT_TOKEN}" \
-d "payer_first_name=John" \
-d "payer_last_name=Smith" \
-d "[email protected]" \
-d "payer_phone=998901234567" \
-d "payer_country=UZ" \
-d "payer_city=Tashkent" \
-d "payer_address=5 Amir Temur Ave" \
-d "payer_zip=100000" \
-d "payer_ip=203.0.113.10" \
-d "term_url_3ds=https://yoursite.example/3ds-return" \
-d "hash=5a3ad716b1b70d6e0a8d5f06548cf7ea"$url = 'https://{PAYMENT_URL}/post';
$fields = [
'action' => 'SALE',
'client_key' => '{CLIENT_KEY}',
'order_id' => 'WAL-3001',
'order_amount' => '120000',
'order_currency' => 'UZS',
'order_description' => 'Order WAL-3001',
'digital_wallet' => 'googlepay',
'payment_token' => '{WALLET_PAYMENT_TOKEN}',
'payer_first_name' => 'John',
'payer_last_name' => 'Smith',
'payer_email' => '[email protected]',
'payer_phone' => '998901234567',
'payer_country' => 'UZ',
'payer_city' => 'Tashkent',
'payer_address' => '5 Amir Temur Ave',
'payer_zip' => '100000',
'payer_ip' => '203.0.113.10',
'term_url_3ds' => 'https://yoursite.example/3ds-return',
'hash' => '5a3ad716b1b70d6e0a8d5f06548cf7ea',
];
$body = http_build_query($fields);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);import requests
from urllib.parse import urlencode
url = 'https://{PAYMENT_URL}/post'
fields = {
'action': 'SALE',
'client_key': '{CLIENT_KEY}',
'order_id': 'WAL-3001',
'order_amount': '120000',
'order_currency': 'UZS',
'order_description': 'Order WAL-3001',
'digital_wallet': 'googlepay',
'payment_token': '{WALLET_PAYMENT_TOKEN}',
'payer_first_name': 'John',
'payer_last_name': 'Smith',
'payer_email': '[email protected]',
'payer_phone': '998901234567',
'payer_country': 'UZ',
'payer_city': 'Tashkent',
'payer_address': '5 Amir Temur Ave',
'payer_zip': '100000',
'payer_ip': '203.0.113.10',
'term_url_3ds': 'https://yoursite.example/3ds-return',
'hash': '5a3ad716b1b70d6e0a8d5f06548cf7ea',
}
body = urlencode(fields)
response = requests.post(
url,
data=body,
headers={'Content-Type': 'application/x-www-form-urlencoded'},
)
result = response.json()const url = 'https://{PAYMENT_URL}/post'
const fields = {
'action': 'SALE',
'client_key': '{CLIENT_KEY}',
'order_id': 'WAL-3001',
'order_amount': '120000',
'order_currency': 'UZS',
'order_description': 'Order WAL-3001',
'digital_wallet': 'googlepay',
'payment_token': '{WALLET_PAYMENT_TOKEN}',
'payer_first_name': 'John',
'payer_last_name': 'Smith',
'payer_email': '[email protected]',
'payer_phone': '998901234567',
'payer_country': 'UZ',
'payer_city': 'Tashkent',
'payer_address': '5 Amir Temur Ave',
'payer_zip': '100000',
'payer_ip': '203.0.113.10',
'term_url_3ds': 'https://yoursite.example/3ds-return',
'hash': '5a3ad716b1b70d6e0a8d5f06548cf7ea',
}
const body = new URLSearchParams(fields).toString()
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const result = await response.json()For Apple Pay, send digital_wallet=applepay and the full Apple Pay token JSON as payment_token. Everything else is identical.
The callback verifies differently from the request#
The request signs with Formula 8. The callback does not.
Wallet flows verify their callbacks with Formula 2, using the stored card mask, exactly as a card sale does. So a wallet payment involves two different constructions, one outbound and one inbound, and reusing the request digest to verify the callback fails every time.
action=SALE result=SUCCESS status=SETTLED order_id=WAL-3001 trans_id=a7b8c9d0-1e2f-4a3b-8c4d-5e6f7a8b9c0d trans_date=2026-07-25 17:04:12 descriptor=CENTAPAY WALLET amount=120000 currency=UZS card=411111****1111 card_expiration_date=01/2038 digital_wallet=googlepay pan_type=DPAN hash=9a047925bcf958b5f1fe68ae28056bd6
Wallet callbacks carry two fields a card sale does not. digital_wallet names the provider, and pan_type is DPAN or FPAN, present on wallet transactions only.
The two digests on this page come from the same customer and password and differ entirely, because Formula 8 and Formula 2 are different constructions. If yours match, you have used one of them twice.
Virtual flow and card flow#
By default, wallet payments are classified as virtual. Card details are not stored, and DMS and recurring creation are limited.
To enable card flow, where the token is decrypted and card data stored for recurring use, set up the Processing Private Key in the admin panel and verify provider support.
Choose before you build. A recurring product on virtual-flow wallets will not behave as you expect, and the switch is an account configuration rather than a code change.
Apple Pay setup#
In your Apple Developer account:
- Create a Merchant ID in Certificates, Identifiers and Profiles
- Register and verify all payment domains
- Create a Merchant Identity Certificate, generating
*.csrand*.key, uploading the CSR and downloading the*.pem
Then configure it under Merchants, Wallets, Apple Pay in the admin panel.
The client-side flow is Apple's: check availability with ApplePaySession.canMakePayments(), show the button per Apple's UX guidelines, validate merchant identity through a server-side validation session, then create the payment request and send the resulting token to your server.
Google Pay setup#
Review the Google Pay Web or Android documentation, complete the integration checklist and branding requirements, verify domains in Google Business Console, and adhere to the Google Pay APIs Acceptable Use Policy and Terms of Service.
Request PaymentData with these parameters:
allowPaymentMethods: CARDtokenizationSpecification: { "type": "PAYMENT_GATEWAY" }allowedCardNetworks: {ENABLED_CARD_NETWORKS}- the networks enabled on your account, confirmed during onboardingallowedCardAuthMethods: ['PAN_ONLY', 'CRYPTOGRAM_3DS']gateway= value from your CentaPay account managergatewayMerchantId= yourCLIENT_KEY
The Environment setting must match between your Google Pay configuration and your CentaPay account, TEST or PRODUCTION.
Set allowedCardNetworks to what your account can actually acquire
This array populates the Google Pay payment sheet, so any network you list is offered to the cardholder. If they choose one your account cannot acquire, Google returns a valid token, the SALE declines, and the customer has already been told they paid.
It is the one parameter on this page where a copied example is actively dangerous. Take the value from onboarding and list nothing beyond it.
PAN_ONLY moves 3DS responsibility
For the PAN_ONLY authentication method, 3D Secure responsibility transfers to the acquirer. Confirm your acquirer supports this before enabling it.
What next#
Take a payment
covers the card sale, authorise and capture, and saving a card.
Handle 3D Secure
covers the redirect.
Bill recurring
covers scheduled charging, which interacts with the virtual and card flow choice above.
The API reference
lists every parameter of every action. Idempotency covers order IDs, timeouts and what to do when an outcome is unknown.
Technical questions go to [email protected].