CentaPay

Last updated

Handle 3D Secure#

Send a cardholder to their bank to authenticate, and pick the payment up again when they come back.

Availability. CentaPay is pre-launch. Card acceptance in Uzbekistan runs on HUMO and UZCARD, with production expected in Q4 2026 and sandbox access ahead of it. Coverage differs by market, and Kazakhstan and Pakistan launch on other payment methods rather than cards. The coverage table on our main site is the single source for what is live where, and for current dates. Nothing in this guide should be read as a service available today.

Most Kazakhstan and Uzbekistan card transactions trigger 3DS, so this is the path most of your live traffic takes rather than an edge case. The single-step sale in Take a payment is the exception.

Every example below uses the same sample credentials so the hashes are reproducible: payer_email of [email protected], a PASSWORD of SANDBOX_PASSWORD, and the sandbox test card. Payload values are illustrative, but every field name, its presence or absence, and every hash is exact.

The shape of an authenticated payment#

Six steps, and the last one is the only authoritative signal.

You send a SALE. You get back result: REDIRECT with a redirect_url. You put the cardholder's browser on that URL. They authenticate with their issuer, by SMS, biometric, or frictionlessly with no interaction at all. They return to your term_url_3ds. The final result arrives by callback.

The return leg is not the result. A cardholder can land back on your site before the callback arrives, and can land back on it having abandoned the challenge entirely. Treat the return as a signal to show a waiting state, and nothing more.

The redirect response#

A SALE that needs authentication returns result: REDIRECT and status: 3DS. The request itself is identical to a non-3DS sale, including its signature, so nothing about how you build it changes.

The request

One field differs from the single-step sale: an expiry of 05/2038, which selects the 3DS challenge from the test-card table below.

POSThttps://{PAYMENT_URL}/post
curl -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=TDS-2001" \
  -d "order_amount=120000" \
  -d "order_currency=UZS" \
  -d "order_description=Order TDS-2001" \
  -d "card_number=4111111111111111" \
  -d "card_exp_month=05" \
  -d "card_exp_year=2038" \
  -d "card_cvv2=123" \
  -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=c8b58f1a6a6083fd4f0bd17d3ef58a45"
$url = 'https://{PAYMENT_URL}/post';

$fields = [
    'action' => 'SALE',
    'client_key' => '{CLIENT_KEY}',
    'order_id' => 'TDS-2001',
    'order_amount' => '120000',
    'order_currency' => 'UZS',
    'order_description' => 'Order TDS-2001',
    'card_number' => '4111111111111111',
    'card_exp_month' => '05',
    'card_exp_year' => '2038',
    'card_cvv2' => '123',
    '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' => 'c8b58f1a6a6083fd4f0bd17d3ef58a45',
];

$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': 'TDS-2001',
    'order_amount': '120000',
    'order_currency': 'UZS',
    'order_description': 'Order TDS-2001',
    'card_number': '4111111111111111',
    'card_exp_month': '05',
    'card_exp_year': '2038',
    'card_cvv2': '123',
    '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': 'c8b58f1a6a6083fd4f0bd17d3ef58a45',
}

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': 'TDS-2001',
  'order_amount': '120000',
  'order_currency': 'UZS',
  'order_description': 'Order TDS-2001',
  'card_number': '4111111111111111',
  'card_exp_month': '05',
  'card_exp_year': '2038',
  'card_cvv2': '123',
  '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': 'c8b58f1a6a6083fd4f0bd17d3ef58a45',
}

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()

The signature is the one the non-3DS sale carries. Formula 1 reads payer_email, PASSWORD and the card's first six and last four digits, and none of those moved, so changing the expiry to select a 3DS outcome does not change the hash. A 3DS request that fails on the signature is failing for some other reason.

term_url_3ds is where the cardholder lands after authenticating. Here it is load-bearing rather than a formality.

{
  "action": "SALE",
  "result": "REDIRECT",
  "status": "3DS",
  "order_id": "TDS-2001",
  "trans_id": "e5f6a7b8-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
  "trans_date": "2026-07-25 14:32:07",
  "descriptor": "CENTAPAY TDS",
  "amount": "120000",
  "currency": "UZS",
  "redirect_url": "https://acs.example/3ds/challenge",
  "redirect_method": "POST",
  "redirect_params": { "PaReq": "eJxVUt1...", "TermUrl": "https://yoursite.example/3ds-return" }
}

status can also be REDIRECT rather than 3DS. Both mean the same thing to your code: send the cardholder to redirect_url.

Keep the trans_id. It is how you match the callback to the payment, and it is an input to the callback signature.

redirect_params is not guaranteed

redirect_params varies by acquirer. It may contain PaReq, TermUrl, or other values. It may be empty, and it may be absent altogether, most commonly when redirect_method is GET.

Check for it before you iterate it. Code that assumes an array here fails on the acquirer that sends nothing, and it fails at the moment a real cardholder is waiting.

Sending the cardholder#

Two shapes, chosen by redirect_method.

A self-submitting form, for POST

$html = '<form id="3ds" method="'
  . $response['redirect_method']
  . '" action="' . $response['redirect_url'] . '">';
if (!empty($response['redirect_params']) && is_array($response['redirect_params'])) {
    foreach ($response['redirect_params'] as $k => $v) {
        $html .= '<input type="hidden" name="' . htmlspecialchars($k)
          . '" value="' . htmlspecialchars($v) . '">';
    }
}
$html .= '</form><script>document.getElementById("3ds").submit();</script>';
echo $html;

The !empty and is_array guard is the part that matters. It is what makes the same code work for an acquirer that sends no parameters.

A location change, for GET

document.location = response.redirect_url;

Choosing your endpoint by how you parse#

The two endpoints differ in one respect: the shape of redirect_params.

/post returns it as a key-value object.

"redirect_params": { "PaReq": "eJxVUt1...", "TermUrl": "https://..." }

/v2/post returns it as an array of objects.

"redirect_params": [
  {"name": "PaReq", "value": "eJxVUt1..."},
  {"name": "TermUrl", "value": "https://..."}
]

Neither is more correct. Pick the one your form builder consumes without reshaping, and use it consistently, because a payload that changes shape between endpoints is a bug waiting for the day someone switches.

Returning into an iframe#

If your checkout runs inside an iframe, term_url_target controls where the cardholder lands when the issuer sends them back.

Values are _blank, _self, _parent, _top, or a custom iframe name. _top is the default.

The default breaks out of the iframe, which is usually what you want, because many issuers refuse to render their challenge inside a frame at all. Set it deliberately rather than inheriting it.

The callback, and the field that is missing#

The final outcome arrives at your callback URL.

action=SALE
result=SUCCESS
status=SETTLED
order_id=TDS-2001
trans_id=e5f6a7b8-9c0d-4e1f-a2b3-c4d5e6f7a8b9
trans_date=2026-07-25 14:41:55
descriptor=CENTAPAY TDS
amount=120000
currency=UZS
card=411111****1111
card_expiration_date=01/2038
hash=4008791549623c0d41f3cb0bd9816d79

Verify with Formula 2, over payer_email, PASSWORD, trans_id and the card's first six and last four characters.

Here is the trap this flow sets, and it is specific to 3DS.

You will receive more than one callback. A redirect callback arrives first, carrying result: REDIRECT and status: 3DS or REDIRECT, and the final callback follows it. Treat trans_id plus result as the unit of work and make your handler idempotent. A repeated callback must not create a second fulfilment.

The redirect callback has no card field. It carries action, result, status, order_id, trans_id, trans_date, descriptor, amount, currency, redirect_url, redirect_params, redirect_method, custom_data, digital_wallet, pan_type and hash. It does not carry card or card_expiration_date.

Formula 2 needs a card fragment, so verifying that callback means falling back to the mask you stored when you created the payment. The same is true of an undefined callback, which also omits both fields.

The masked PAN behaves exactly like the full one. First six characters joined to the last four gives 4111111111, and the asterisks are never touched.

trans_id is uppercased along with everything else before hashing. A lowercase UUID that is not uppercased is a silent mismatch, and it is the most common cause of a callback that fails verification while the request hash worked.

Return the plain string OK once you have accepted the notification, or ERROR if you have not.

Test cards#

The expiry date selects the outcome. Sandbox scheme coverage is a testing convenience and does not indicate which schemes are enabled on your live account.

ExpiryOutcome
05/20383DS challenge, then success
06/20383DS challenge, then decline
12/2038Redirect, then success
12/2039Redirect, then decline

05/2038 and 06/2038 return status: 3DS. 12/2038 and 12/2039 return status: REDIRECT. Exercise both, because your code branches on redirect_method and not on status, and the two pairs can differ.

What next#

Technical questions go to [email protected].