CentaPay

Last updated

Quickstart#

Take a card payment on the CentaPay sandbox and verify the callback that confirms it.

Availability. CentaPay is pre-launch. Production is expected in Q4 2026, with sandbox access ahead of it. Coverage differs by market, and the payment methods available differ by market too. The coverage table on our main site is the single source for what is live where, and for current dates. Nothing in this documentation should be read as a service available today.

Budget about fifteen minutes. Step 1 needs no credentials, so you can validate your hash implementation while your account is being set up.

The API is server-to-server. Every operation is a POST to a single endpoint with Content-Type: application/x-www-form-urlencoded, and every response is JSON. There are no bearer tokens and no API key headers. Requests are signed with an MD5 hash.

Before you begin#

Credentials are issued by hand after we whitelist your infrastructure. Send three things to [email protected]:

WhatWhy
IP listThe addresses your server will send from. Requests from any other address are rejected without a response, so an incomplete list looks like a network fault.
Callback URLWhere we POST transaction results. Maximum 255 characters. Mandatory if your account supports 3D Secure.
Contact emailThe person who will monitor transactions, handle refunds and answer operational queries.

You receive three values back:

ValueUse
CLIENT_KEYSent as a parameter on every request.
PASSWORDUsed only to compute hashes. It never leaves your server and is never sent in a request.
PAYMENT_URLYour endpoint host. Sandbox and production are different hosts.

Two conditions must also be true before your first call succeeds. Your sending IP addresses must be whitelisted, and the S2S CARD protocol must be mapped to your account. Both are done by CentaPay during onboarding. If either is missing you cannot process payments.

Throughout this guide, {PAYMENT_URL}, {CLIENT_KEY} and {PASSWORD} are placeholders for the values issued to you.

1. Check your hash implementation#

Do this first. A wrong hash is the most common integration failure, and it is indistinguishable from a credentials problem when you are looking at an error response. You can complete this step offline, before your account exists.

A SALE with card data is signed with Formula 1:

md5(strtoupper(
  strrev(payer_email)
  . PASSWORD
  . strrev(substr(card_number, 0, 6) . substr(card_number, -4))
))

Three things catch people out. The card component is the first six digits joined to the last four with nothing in between, and that ten-character string is reversed as a unit rather than reversing each part. The whole concatenated string is uppercased after assembly, not before. And if a formula references an optional parameter you are not sending, leave it out of the calculation entirely.

Test vector

Run your implementation against these inputs. If you do not get this digest, stop and fix it before going further.

InputValue
payer_email[email protected]
PASSWORDSANDBOX_PASSWORD
card_number4111111111111111
StageValue
Card component4111111111
Card component reversed1111111114
Concatenated and uppercasedMOC.ELPMAXE@NHOJSANDBOX_PASSWORD1111111114
Expected hashc8b58f1a6a6083fd4f0bd17d3ef58a45

Implementations

Every sample on this site is executed against a real interpreter before it is published, and its fields are compared to the cURL it derives from. The versions they are verified on are PHP 8.1, Python 3.9, Node 18 and curl 7.68. Older interpreters are not tested and may differ, most likely in how they encode the form body.

# cURL and shell
EMAIL="[email protected]"
PASSWORD="SANDBOX_PASSWORD"
PAN="4111111111111111"

rev() { echo -n "$1" | rev; }
CARD_PART="${PAN:0:6}${PAN: -4}"
RAW="$(rev "$EMAIL")${PASSWORD}$(rev "$CARD_PART")"
HASH=$(printf '%s' "$RAW" | tr '[:lower:]' '[:upper:]' | openssl md5 -r | cut -d' ' -f1)
echo "$HASH"
<?php
function saleHash(string $email, string $password, string $pan): string
{
    $cardPart = substr($pan, 0, 6) . substr($pan, -4);
    $raw = strrev($email) . $password . strrev($cardPart);
    return md5(strtoupper($raw));
}

echo saleHash('[email protected]', 'SANDBOX_PASSWORD', '4111111111111111');
import hashlib


def sale_hash(email: str, password: str, pan: str) -> str:
    card_part = pan[:6] + pan[-4:]
    raw = email[::-1] + password + card_part[::-1]
    return hashlib.md5(raw.upper().encode()).hexdigest()


print(sale_hash("[email protected]", "SANDBOX_PASSWORD", "4111111111111111"))
const crypto = require('crypto');

const rev = (s) => s.split('').reverse().join('');

function saleHash(email, password, pan) {
  const cardPart = pan.slice(0, 6) + pan.slice(-4);
  const raw = rev(email) + password + rev(cardPart);
  return crypto.createHash('md5').update(raw.toUpperCase()).digest('hex');
}

console.log(saleHash('[email protected]', 'SANDBOX_PASSWORD', '4111111111111111'));

If you would rather check a single value by hand than wire up a test, the Hash Calculator further down this page computes Formulas 1, 2, 5 and 8 in the browser, and the Request Builder assembles a complete signed SALE for you. Neither sends anything anywhere.

2. Send your first payment#

This is a UZS sale for 120,000 som. Amounts in UZS and KZT are integers with no decimal component. Amounts in USD are decimal, formatted as XX.XX.

order_id must be unique for every payment you create. Reusing one returns error code 400.

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=QS-0001" \
  -d "order_amount=120000" \
  -d "order_currency=UZS" \
  -d "order_description=Quickstart test payment" \
  -d "card_number=4111111111111111" \
  -d "card_exp_month=01" \
  -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={CALCULATED_HASH}"
$url = 'https://{PAYMENT_URL}/post';

$fields = [
    'action' => 'SALE',
    'client_key' => '{CLIENT_KEY}',
    'order_id' => 'QS-0001',
    'order_amount' => '120000',
    'order_currency' => 'UZS',
    'order_description' => 'Quickstart test payment',
    'card_number' => '4111111111111111',
    'card_exp_month' => '01',
    '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' => '{CALCULATED_HASH}',
];

$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': 'QS-0001',
    'order_amount': '120000',
    'order_currency': 'UZS',
    'order_description': 'Quickstart test payment',
    'card_number': '4111111111111111',
    'card_exp_month': '01',
    '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': '{CALCULATED_HASH}',
}

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': 'QS-0001',
  'order_amount': '120000',
  'order_currency': 'UZS',
  'order_description': 'Quickstart test payment',
  'card_number': '4111111111111111',
  'card_exp_month': '01',
  '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': '{CALCULATED_HASH}',
}

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

payer_ip is the cardholder's address, not your server's. term_url_3ds is required even on this non-3DS scenario.

About the test card

Every sandbox scenario uses the same test PAN, and 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. Your enabled schemes are confirmed during onboarding.

ExpiryOutcome
01/2038Immediate success. The only expiry that returns a recurring token.
02/2038Declined by processing.
03/2038AUTH succeeds, CAPTURE declines.
05/20383D Secure challenge, then success.
06/20383D Secure challenge, then decline.
12/2038Redirect, then success.
12/2039Redirect, then decline.

Any three-digit CVV works. No funds move.

3. Read the synchronous response#

{
  "action": "SALE",
  "result": "SUCCESS",
  "status": "SETTLED",
  "order_id": "QS-0001",
  "trans_id": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "trans_date": "2026-07-25 14:32:07",
  "descriptor": "CENTAPAY QUICKSTART",
  "amount": "120000",
  "currency": "UZS"
}

Record trans_id. You will need it for captures, refunds, status queries and callback verification.

Do not fulfil an order on this response. The synchronous response tells you the request was accepted and what happened at that instant. It is not the final outcome. result can also come back as REDIRECT when 3D Secure is required, or UNDEFINED when the outcome is not yet known. The callback is authoritative in every case, including this one.

If a request times out or the connection drops, do not resend it. Call GET_TRANS_STATUS_BY_ORDER with your order_id and read the actual state. Blind resubmission creates duplicate orders, and where cascading is enabled a single payment request can generate several underlying transactions.

4. Receive and verify the callback#

The callback is the result. Everything else is provisional.

The contract

We POST to your callback URL as application/x-www-form-urlencoded. Your endpoint returns the plain string OK if it accepted the notification, or ERROR if it did not. Anything else, including a timeout, counts as a failure.

Failures are penalised at the URL level, not the account level. Five timeouts within five minutes block that callback URL for fifteen minutes, and every merchant sharing the URL stops receiving notifications for the duration. A single successful response resets the counter. The block lifts automatically, and can also be cleared immediately by CentaPay on request.

Two consequences for your design. Return OK before doing slow work: acknowledge the callback, queue the job, process it out of band. And do not share one callback URL across environments or entities, because one broken consumer takes the others down with it.

What arrives

Field names, presence and formats below are exact. Values are illustrative.

action=SALE
result=SUCCESS
status=SETTLED
order_id=QS-0001
trans_id=a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
trans_date=2026-07-25 14:32:09
descriptor=CENTAPAY QUICKSTART
amount=120000
currency=UZS
card=411111****1111
card_expiration_date=01/2038
hash=9c49c27cea14b586c4ad8f0bc0dbda24

Three properties of this payload drive most integration bugs.

The payer's email is not in it. Formula 2 needs the email, so you must store it against your order_id at the point you create the payment and retrieve it when the callback arrives.

The card field is present on success and absent on decline. A declined callback carries a reduced field set: action, result, status, order_id, trans_id, trans_date, decline_reason, custom_data, digital_wallet, pan_type and hash. It has no card, descriptor, amount or currency. Verification still needs a card component, so fall back to the mask you stored from your own request.

The callback for a 3D Secure payment arrives more than once. You will receive a redirect callback and then a final callback. Treat trans_id plus result as the unit of work and make your handler idempotent, because a repeated callback must not create a second fulfilment.

Verifying the hash

Callbacks for every action except CREDIT2CARD and VOID use Formula 2:

md5(strtoupper(
  strrev(payer_email)
  . PASSWORD
  . trans_id
  . strrev(substr(card, 0, 6) . substr(card, -4))
))

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

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

Test vector

InputValue
payer_email[email protected]
PASSWORDSANDBOX_PASSWORD
trans_ida1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d
card411111****1111
StageValue
Card component4111111111
Concatenated and uppercasedMOC.ELPMAXE@NHOJSANDBOX_PASSWORDA1B2C3D4-5E6F-4A7B-8C9D-0E1F2A3B4C5D1111111114
Expected hash9c49c27cea14b586c4ad8f0bc0dbda24

A working receiver

<?php
// POST /callback
$data = $_POST;

$storedEmail    = lookupEmail($data['order_id']);
$storedCardMask = lookupCardMask($data['order_id']);

$cardMask = $data['card'] ?? $storedCardMask;
$cardPart = substr($cardMask, 0, 6) . substr($cardMask, -4);

$expected = md5(strtoupper(
    strrev($storedEmail) . PASSWORD . $data['trans_id'] . strrev($cardPart)
));

if (!hash_equals($expected, $data['hash'] ?? '')) {
    http_response_code(400);
    exit('ERROR');
}

// Acknowledge first, process afterwards.
enqueue($data);
echo 'OK';
import hashlib
import hmac

from flask import Flask, request

app = Flask(__name__)


@app.post("/callback")
def callback():
    data = request.form.to_dict()

    stored_email = lookup_email(data["order_id"])
    stored_mask = lookup_card_mask(data["order_id"])

    card_mask = data.get("card", stored_mask)
    card_part = card_mask[:6] + card_mask[-4:]

    raw = stored_email[::-1] + PASSWORD + data["trans_id"] + card_part[::-1]
    expected = hashlib.md5(raw.upper().encode()).hexdigest()

    if not hmac.compare_digest(expected, data.get("hash", "")):
        return "ERROR", 400

    enqueue(data)
    return "OK"
const crypto = require('crypto');
const express = require('express');

const app = express();
const rev = (s) => s.split('').reverse().join('');

app.post('/callback', express.urlencoded({ extended: false }), (req, res) => {
  const d = req.body;

  const storedEmail = lookupEmail(d.order_id);
  const storedMask = lookupCardMask(d.order_id);

  const cardMask = d.card || storedMask;
  const cardPart = cardMask.slice(0, 6) + cardMask.slice(-4);

  const raw = rev(storedEmail) + PASSWORD + d.trans_id + rev(cardPart);
  const expected = crypto.createHash('md5').update(raw.toUpperCase()).digest('hex');

  const given = Buffer.from(d.hash || '', 'utf8');
  const want = Buffer.from(expected, 'utf8');
  if (given.length !== want.length || !crypto.timingSafeEqual(given, want)) {
    return res.status(400).send('ERROR');
  }

  enqueue(d);
  res.send('OK');
});

Compare hashes in constant time and reject anything that does not match. An unverified callback is an unauthenticated instruction to release goods.

5. See a 3D Secure transaction#

Most Kazakhstan and Uzbekistan card traffic is authenticated, so the flow in step 2 is the exception rather than the rule. Repeat your SALE with expiry 05/2038 and the response changes shape:

{
  "action": "SALE",
  "result": "REDIRECT",
  "status": "3DS",
  "order_id": "QS-0002",
  "trans_id": "b2c3d4e5-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
  "trans_date": "2026-07-25 14:41:55",
  "descriptor": "CENTAPAY QUICKSTART",
  "amount": "120000",
  "currency": "UZS",
  "redirect_url": "https://acs.example/3ds/challenge",
  "redirect_method": "POST",
  "redirect_params": { "PaReq": "eJxVUt1...", "TermUrl": "https://yoursite.example/3ds-return" }
}

You post the cardholder's browser to redirect_url using redirect_method, carrying redirect_params as form fields. They authenticate with their issuer and return to your term_url_3ds. The final outcome arrives by callback, not on the return leg. Treat the return purely as a signal to show a waiting state.

redirect_params varies by acquirer and can be empty or absent, most often when redirect_method is GET. Always check before iterating it.

Handling this properly, including the iframe and term_url_target cases, is covered in the 3D Secure guide.

What next#

Before you move to production, work through the go-live checklist. It covers callback verification, IP whitelisting on your production addresses, error handling and reconciliation.

Technical questions go to [email protected]. Commercial questions go to [email protected].