Take payments with four
lines of setup.
EkstraPAY V2 handles deposits and withdrawals over one signed endpoint. Drop in a client for Python, PHP, Node.js or C#, and the signing, serialization and callback verification are already done.
Every request carries the HMAC-SHA256 digest of that line in X-Signature. The server rebuilds the same line and rejects anything that differs by a single character — which is the one thing worth understanding before you start.
Get the client
Each package is a single source file plus a README. No package manager, no external dependencies beyond what your runtime already ships.
Python
—requestsPHP
—Node.js
—C# / ASP.NET
—All four clients
One archive, one folder per language.
Prefer to click before you code?
The full collection lives in Postman — every endpoint with saved example responses, and a pre-request script that signs each call for you. Import it, drop in your apiKey and apiSecret collection variables, and send a real request in under a minute.
What the client covers
| Operation | Endpoint | Method |
|---|---|---|
| Create deposit | POST /api/v1/payment/create | createDeposit |
| Create withdraw | POST /api/v1/payment/create | createWithdraw |
| Check transaction | POST /api/v1/payment/check | check |
| Cancel transaction | POST /api/v1/payment/cancel | cancel |
| Verify a callback | inbound | parseCallback |
Your first request
Copy the source file into your project, construct a client with your credentials, and call one method. The client signs the request, sends the exact bytes it signed, and hands back a parsed result.
Keep credentials out of source control — environment variables or your framework's config layer.
Read this before you ship anything. A transaction exists only when status is "success". In every other case nothing was created, and the customer must be shown the API's message — or description when message is absent. The client gives you both as created and customerMessage.
Before going live, send EkstraPAY your deposit callback URL, your withdraw callback URL, and the IP addresses to whitelist. Requests from unlisted IPs are rejected with HTTP 401.
from ekstrapay import EkstraPay
pay = EkstraPay(
api_key=os.environ["EKSTRAPAY_KEY"],
api_secret=os.environ["EKSTRAPAY_SECRET"],
)
result = pay.create_deposit(
amount=100,
customer_fullname="Lorem Ipsum",
customer_username="user_42",
customer_id="CUST-789456",
customer_site="example.com",
)
if result.created:
print(result.bank.account_iban)
print(result.transaction_id)
else:
print(result.customer_message) # show this to the customeruse EkstraPay\EkstraPay;
$pay = new EkstraPay(
getenv('EKSTRAPAY_KEY'),
getenv('EKSTRAPAY_SECRET')
);
$result = $pay->createDeposit([
'amount' => 100,
'customer_fullname' => 'Lorem Ipsum',
'customer_username' => 'user_42',
'customer_id' => 'CUST-789456',
'customer_site' => 'example.com',
]);
if ($result->created()) {
echo $result->bank->accountIban;
echo $result->transactionId();
} else {
echo $result->customerMessage(); // show this to the customer
}const { EkstraPay } = require('./ekstrapay');
const pay = new EkstraPay(
process.env.EKSTRAPAY_KEY,
process.env.EKSTRAPAY_SECRET
);
const result = await pay.createDeposit({
amount: 100,
customer_fullname: 'Lorem Ipsum',
customer_username: 'user_42',
customer_id: 'CUST-789456',
customer_site: 'example.com',
});
if (result.created) {
console.log(result.bank.accountIban);
console.log(result.transactionId);
} else {
console.log(result.customerMessage); // show this to the customer
}using EkstraPaySdk;
var pay = new EkstraPayClient(
config["EkstraPay:ApiKey"]!,
config["EkstraPay:ApiSecret"]!);
var result = await pay.CreateDepositAsync(new DepositRequest
{
Amount = 100,
CustomerFullname = "Lorem Ipsum",
CustomerUsername = "user_42",
CustomerId = "CUST-789456",
CustomerSite = "example.com",
});
if (result.Created)
{
Console.WriteLine(result.Bank.AccountIban);
Console.WriteLine(result.TransactionId);
}
else
{
Console.WriteLine(result.CustomerMessage); // show this to the customer
}How requests are signed
You never write this code — the client does it on every call. It is documented so you can reproduce it by hand when a signature is rejected.
- Take the Unix timestamp in seconds.
- Generate a 32-character hex nonce.
- Drop every field that is
nullor an empty string. - Serialize to compact JSON, without escaping non-ASCII characters.
- Join with pipes:
apiKey|timestamp|nonce|jsonString. - HMAC-SHA256 it with your API secret, lowercase hex.
- Send that JSON string as the body — the same bytes you signed.
Timestamp and nonce together make each request unique. A captured request cannot be replayed: the timestamp goes stale and the nonce is single-use server-side.
| Header | Value |
|---|---|
| X-API-Key | Your API key |
| X-Timestamp | Unix timestamp used in the signature |
| X-Nonce | Hex nonce used in the signature |
| X-Signature | HMAC-SHA256 hex digest, lowercase |
| Content-Type | application/json |
timestamp = int(time.time())
nonce = secrets.token_hex(16)
clean = {k: v for k, v in body.items()
if v is not None and v != ""}
json_string = json.dumps(
clean, ensure_ascii=False, separators=(",", ":")
)
message = f"{api_key}|{timestamp}|{nonce}|{json_string}"
signature = hmac.new(
api_secret.encode(), message.encode(), hashlib.sha256
).hexdigest()$timestamp = time();
$nonce = bin2hex(random_bytes(16));
$clean = array_filter($body, fn($v) => $v !== null && $v !== '');
$jsonString = json_encode(
$clean,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$message = implode('|', [$apiKey, $timestamp, $nonce, $jsonString]);
$signature = hash_hmac('sha256', $message, $apiSecret);const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(16).toString('hex');
const clean = Object.fromEntries(
Object.entries(body).filter(
([, v]) => v !== null && v !== undefined && v !== ''
)
);
const jsonString = JSON.stringify(clean);
const message = [apiKey, timestamp, nonce, jsonString].join('|');
const signature = crypto
.createHmac('sha256', apiSecret)
.update(message, 'utf8')
.digest('hex');var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var nonce = Convert.ToHexString(
RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
// Utf8JsonWriter with UnsafeRelaxedJsonEscaping,
// empty fields removed before writing.
var jsonString = Serialize(StripEmpty(body));
var message = $"{apiKey}|{timestamp}|{nonce}|{jsonString}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiSecret));
var signature = Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(message))
).ToLowerInvariant();Create deposit
Creates a collection transaction. When one is created the response carries the bank account your customer should transfer to — IBAN, holder and the exact amount.
The one rule
A transaction is created only when status is "success". Anything else means nothing was created, whatever the rest of the body contains — and the customer has to be told why, using the message field (or description when message is absent). The client exposes both as created and customerMessage.
Never show your own wording instead. The API writes message in the customer's language and it explains the actual reason — amount out of range, an open transaction still pending, no available provider. Swallowing it leaves the customer staring at a form that silently did nothing.
Why it failed
Once you know nothing was created, outcome tells you what to do next. It folds status and data.type into one value.
| outcome | status | Meaning |
|---|---|---|
| new | success | Transaction created. Show the bank details. |
| on_process | error | The customer already has an open transaction. Nothing new was created — show message and keep them on the pending one. |
| update | error | A different amount was submitted, so the open transaction was updated instead. Still not a new transaction — show message. |
| error | error | Validation or routing failure. Show message. |
A populated data object does not mean a transaction was created. On on_process and update the API returns details of a transaction that already existed. Useful for your own records — but the customer still sees message, not a fresh payment screen.
Amounts come back as strings such as "100.00" in on_process and update responses, and as numbers in a fresh new response. Cast before comparing.
result = pay.create_deposit(
amount=100,
customer_fullname="Lorem Ipsum",
customer_username="user_42",
customer_id="CUST-789456",
customer_site="example.com", # optional
customer_email="[email protected]", # optional
order_id="ORD-1001", # optional
)
if result.created:
show_bank(
result.bank.account_iban,
result.bank.account_holder,
result.bank.amount,
)
save(result.transaction_id)
else:
show_to_customer(result.customer_message)$result = $pay->createDeposit([
'amount' => 100,
'customer_fullname' => 'Lorem Ipsum',
'customer_username' => 'user_42',
'customer_id' => 'CUST-789456',
'customer_site' => 'example.com', // optional
'customer_email' => '[email protected]', // optional
'order_id' => 'ORD-1001', // optional
]);
if ($result->created()) {
$this->showBank(
$result->bank->accountIban,
$result->bank->accountHolder,
$result->bank->amount
);
$this->save($result->transactionId());
} else {
$this->showToCustomer($result->customerMessage());
}const result = await pay.createDeposit({
amount: 100,
customer_fullname: 'Lorem Ipsum',
customer_username: 'user_42',
customer_id: 'CUST-789456',
customer_site: 'example.com', // optional
customer_email: '[email protected]', // optional
order_id: 'ORD-1001', // optional
});
if (result.created) {
showBank(
result.bank.accountIban,
result.bank.accountHolder,
result.bank.amount
);
save(result.transactionId);
} else {
showToCustomer(result.customerMessage);
}var result = await pay.CreateDepositAsync(new DepositRequest
{
Amount = 100,
CustomerFullname = "Lorem Ipsum",
CustomerUsername = "user_42",
CustomerId = "CUST-789456",
CustomerSite = "example.com", // optional
CustomerEmail = "[email protected]", // optional
OrderId = "ORD-1001", // optional
});
if (result.Created)
{
ShowBank(
result.Bank.AccountIban,
result.Bank.AccountHolder,
result.Bank.Amount);
Save(result.TransactionId);
}
else
{
ShowToCustomer(result.CustomerMessage);
}# gate first: nothing was created unless status is "success"
if not result.created:
show_to_customer(result.customer_message) # the API's own wording
log(result.outcome, result.transaction_id) # for your records only
return
show_bank(result.bank)
save(result.transaction_id)
# outcome only explains WHY nothing was created
if result.outcome == "on_process":
keep_customer_on_pending(result.transaction_id)
elif result.outcome == "update":
note_amount_change(result.transaction_id, result.bank.amount)// gate first: nothing was created unless status is "success"
if (!$result->created()) {
$this->showToCustomer($result->customerMessage()); // the API's own wording
$this->log($result->outcome, $result->transactionId()); // for your records only
return;
}
$this->showBank($result->bank);
$this->save($result->transactionId());
// outcome only explains WHY nothing was created
if ($result->outcome === 'on_process') {
$this->keepCustomerOnPending($result->transactionId());
} elseif ($result->outcome === 'update') {
$this->noteAmountChange($result->transactionId(), $result->bank->amount);
}// gate first: nothing was created unless status is "success"
if (!result.created) {
showToCustomer(result.customerMessage); // the API's own wording
log(result.outcome, result.transactionId); // for your records only
return;
}
showBank(result.bank);
save(result.transactionId);
// outcome only explains WHY nothing was created
if (result.outcome === 'on_process') {
keepCustomerOnPending(result.transactionId);
} else if (result.outcome === 'update') {
noteAmountChange(result.transactionId, result.bank.amount);
}// gate first: nothing was created unless status is "success"
if (!result.Created)
{
ShowToCustomer(result.CustomerMessage); // the API's own wording
Log(result.Outcome, result.TransactionId); // for your records only
return;
}
ShowBank(result.Bank);
Save(result.TransactionId);
// outcome only explains WHY nothing was created
if (result.Outcome == "on_process")
KeepCustomerOnPending(result.TransactionId);
else if (result.Outcome == "update")
NoteAmountChange(result.TransactionId, result.Bank.Amount);// outcome: new
{
"status": "success",
"message": "İşlem oluşturuldu",
"data": {
"customer": {
"transaction_id": "fa2ea3e0-0bc6-4463-a415-d0c1f72f44c9",
"order_id": null,
"customer_fullname": "Lorem IPSUM",
"customer_username": "required",
"customer_id": "required",
"amount": 100
},
"bank": {
"account_iban": "TR580006400000143750001552",
"account_holder": "Lorem Ipsum",
"amount": 100
},
"created_at": "2026-05-20 11:08:37",
"type": "new"
}
}
// outcome: on_process
{
"status": "error",
"message": "İşlem mevcut, işlemin tamamlanmasını bekleyiniz",
"data": {
"pool": { "role_type": "marjin", "role_id": 1 },
"customer": {
"transaction_id": "0c96e331-093e-4e24-882b-b3aeaf293a86",
"amount": "100.00"
},
"bank": {
"account_iban": "TR580006400000143750001552",
"account_holder": "Lorem Ipsum",
"amount": "100.00"
},
"type": "on_process"
}
}
// outcome: error — HTTP 400
{
"status": "error",
"title": "HATA",
"message": "Tutar 100,00 ₺ ve 100.000.000.000,00 ₺ arasında olmalıdır",
"timestamp": "2026-05-20T11:19:51+03:00"
}Create withdraw
Same endpoint and the same signing — only the body changes. A withdraw needs the destination IBAN and its holder, and returns no bank block, because the money moves toward the customer rather than from them.
Outcomes are read exactly as for deposits.
The IBAN in these examples is a test account. TR58 0006 4000 0014 3750 0015 52 — holder Lorem IPSUM — exists only for integration testing. Never send a payout to it from production, and never leave it in a code path that can reach live traffic. Replace it with your customer's own IBAN before you go live.
Withdrawals cannot be cancelled over the API. Once created, a payout can only be resolved by the provider. Validate the IBAN and the amount on your side first.
result = pay.create_withdraw(
amount=1000,
customer_fullname="Lorem Ipsum",
customer_username="user_42",
customer_id="CUST-789456",
account_iban="TR580006400000143750001552", # TEST IBAN — never in production
account_holder="Lorem Ipsum",
customer_site="example.com", # optional
order_id="ORD-1002", # optional
)
if not result.created:
show_to_customer(result.customer_message)$result = $pay->createWithdraw([
'amount' => 1000,
'customer_fullname' => 'Lorem Ipsum',
'customer_username' => 'user_42',
'customer_id' => 'CUST-789456',
'account_iban' => 'TR580006400000143750001552', // TEST IBAN — never in production
'account_holder' => 'Lorem Ipsum',
'customer_site' => 'example.com', // optional
'order_id' => 'ORD-1002', // optional
]);
if (!$result->created()) {
$this->showToCustomer($result->customerMessage());
}const result = await pay.createWithdraw({
amount: 1000,
customer_fullname: 'Lorem Ipsum',
customer_username: 'user_42',
customer_id: 'CUST-789456',
account_iban: 'TR580006400000143750001552', // TEST IBAN — never in production
account_holder: 'Lorem Ipsum',
customer_site: 'example.com', // optional
order_id: 'ORD-1002', // optional
});
if (!result.created) {
showToCustomer(result.customerMessage);
}var result = await pay.CreateWithdrawAsync(new WithdrawRequest
{
Amount = 1000,
CustomerFullname = "Lorem Ipsum",
CustomerUsername = "user_42",
CustomerId = "CUST-789456",
AccountIban = "TR580006400000143750001552", // TEST IBAN — never in production
AccountHolder = "Lorem Ipsum",
CustomerSite = "example.com", // optional
OrderId = "ORD-1002", // optional
});
if (!result.Created)
{
ShowToCustomer(result.CustomerMessage);
}// outcome: new
{
"status": "success",
"message": "İşlem oluşturuldu",
"data": {
"customer": {
"transaction_id": "a14eaf62-20ad-49c0-ba2c-c82b03bb7f30",
"order_id": null,
"customer_fullname": "full_name",
"customer_username": "username",
"customer_id": "user_id",
"amount": 1000
},
"created_at": "2026-08-13 07:07:23",
"type": "new"
}
}
// outcome: error — HTTP 400
{
"status": "error",
"title": "HATA",
"message": "Uygun sağlayıcı bulunamadı",
"timestamp": "2026-08-13T13:41:54+03:00"
}Check transaction
Reads the current status of a transaction. Use it for reconciliation and for the rare case where a callback never arrived — not as a polling substitute for callbacks.
The client returns HTTP 400 responses instead of raising, because the API uses them for real answers such as NOT_FOUND. Only transport failures and non-JSON bodies raise an exception.
result = pay.check_deposit("32c0ceb7-37b0-4ab4-8ab5-6e53debecac6")
# or: pay.check(transaction_id, operation="withdraw")
if not result.found:
log("unknown transaction")
elif result.status_value == TransactionStatus.APPROVED:
settle(result.final_amount)
else:
log(result.status_value, TransactionStatus.label(result.status_value))$result = $pay->checkDeposit('32c0ceb7-37b0-4ab4-8ab5-6e53debecac6');
// or: $pay->check($transactionId, 'withdraw');
if (!$result->found()) {
$this->log('unknown transaction');
} elseif ($result->statusValue === TransactionStatus::APPROVED) {
$this->settle($result->finalAmount);
} else {
$this->log($result->statusValue, TransactionStatus::label($result->statusValue));
}const result = await pay.checkDeposit('32c0ceb7-37b0-4ab4-8ab5-6e53debecac6');
// or: pay.check(transactionId, 'withdraw');
if (!result.found) {
log('unknown transaction');
} else if (result.statusValue === TransactionStatus.APPROVED) {
settle(result.finalAmount);
} else {
log(result.statusValue, TransactionStatus.label(result.statusValue));
}var result = await pay.CheckDepositAsync("32c0ceb7-37b0-4ab4-8ab5-6e53debecac6");
// or: await pay.CheckAsync(transactionId, Operation.Withdraw);
if (!result.Found)
Log("unknown transaction");
else if (result.StatusValue == TransactionStatus.Approved)
Settle(result.FinalAmount);
else
Log(result.StatusValue, TransactionStatus.Label(result.StatusValue));// found
{
"status": "success",
"description": "İşlem bulundu",
"data": {
"status": { "value": "PAY_NEW", "label": "Yeni" },
"customer_username": "customer_username",
"customer_id": "customer_id",
"amount": 100,
"final_amount": 0,
"created_date": "2025-10-22 12:21:45",
"updated_at": "2025-10-22 12:21:45"
},
"timestamp": "2025-10-22T12:48:51+03:00"
}
// not found — HTTP 400
{
"status": "error",
"description": "İşlem bulunamadı",
"data": {
"status": { "value": "NOT_FOUND", "label": "İşlem Bulunamadı" }
},
"timestamp": "2026-03-06T15:22:46+03:00"
}Cancel transaction
Cancels an open deposit. Your own order_id is accepted in place of the transaction ID.
| Current status | Cancellable | Result |
|---|---|---|
| PAY_NEW | Yes | Cancelled, credit refunded in full |
| PAY_PROCESSING | Yes | Cancelled, credit refunded in full |
| PAY_APPROVED | No | not_cancellable |
| PAY_REJECTED | No | not_cancellable |
| PAY_CANCELLED | No | not_cancellable |
| PAY_TIMEOUT | No | not_cancellable |
- Deposits only. Sending
withdrawreturns an error, so the client always sendsdeposit. - Transactions under review cannot be cancelled. Contact support instead.
- Credit refund. In credit mode the full
request_amountreturns to your balance. Refund and status change apply together — both or neither. - A cancellation callback is dispatched, exactly like approvals and rejections.
- Concurrency. One operation may act on a transaction at a time. If it is busy, retry after a few seconds. Cancelling twice never refunds twice.
Error messages
| Situation | Message |
|---|---|
| Not found for your API service | İşlem bulunamadı |
| Status already final | Bu işlem iptal edilemez… |
| Under review | Bu işlem incelemeye alınmıştır… |
| Another operation in progress | Bu işlem şu an işleniyor… |
| Operation was withdraw | Çekim işlemleri API üzerinden iptal edilemez |
| API service inactive — 401 | Api durumu |
| IP not whitelisted — 401 | Ip adresi engellendi |
result = pay.cancel("56f07728-259e-47d5-9c28-3b5e071178ec")
if result.succeeded:
mark_cancelled(result.previous_status) # PAY_NEW or PAY_PROCESSING
elif result.not_cancellable:
log("already final:", result.status_value)
else:
log(result.message) # under review, busy, or not found$result = $pay->cancel('56f07728-259e-47d5-9c28-3b5e071178ec');
if ($result->succeeded()) {
$this->markCancelled($result->previousStatus); // PAY_NEW or PAY_PROCESSING
} elseif ($result->notCancellable()) {
$this->log('already final: ' . $result->statusValue);
} else {
$this->log($result->message); // under review, busy, or not found
}const result = await pay.cancel('56f07728-259e-47d5-9c28-3b5e071178ec');
if (result.succeeded) {
markCancelled(result.previousStatus); // PAY_NEW or PAY_PROCESSING
} else if (result.notCancellable) {
log('already final:', result.statusValue);
} else {
log(result.message); // under review, busy, or not found
}var result = await pay.CancelAsync("56f07728-259e-47d5-9c28-3b5e071178ec");
if (result.Succeeded)
MarkCancelled(result.PreviousStatus); // PAY_NEW or PAY_PROCESSING
else if (result.NotCancellable)
Log($"already final: {result.StatusValue}");
else
Log(result.Message); // under review, busy, or not found// cancelled
{
"status": "success",
"message": "İşlem iptal edildi",
"data": {
"status": { "value": "PAY_CANCELLED", "label": "İptal Edildi" },
"previous_status": "PAY_PROCESSING",
"customer_username": "required12",
"order_id": null,
"customer_id": "required12",
"amount": 100,
"final_amount": 100,
"created_date": "2026-07-28 21:41:39",
"canceled_date": "2026-07-28 21:42:06",
"updated_at": "2026-07-28 21:42:05"
},
"timestamp": "2026-07-28T21:42:05+03:00"
}
// not cancellable — HTTP 400
{
"status": "error",
"message": "Bu işlem iptal edilemez. Mevcut durum: İptal Edildi",
"data": {
"status": { "value": "PAY_CANCELLED", "label": "İptal Edildi" },
"type": "not_cancellable"
},
"timestamp": "2026-07-28T21:41:11+03:00"
}Because the error body carries the current status, you can branch on it without a separate /payment/check call.
Callbacks
EkstraPAY POSTs the outcome of every transaction to the URLs you registered. Verify the hash before acting on the payload, then answer with the exact acknowledgement body.
How the hash is built
These fields, in this order, empty ones skipped, joined with a colon and hashed with SHA-256:
| transaction_id | customer_fullname |
| customer_username | customer_id |
| customer_description | request_amount |
| request_final_amount |
Acknowledgement
Answer with this body. Anything else is treated as a delivery failure and the callback is retried.
{ "status": true, "message": "Callback Received" }
Verify before you credit. The hash is the only thing separating a real callback from a forged POST to a public URL. Reject on mismatch and log it — never fall back to trusting the payload.
Stay idempotent. Retries and status changes arrive on the same URL. Key your updates on transaction_id so a repeated approval cannot credit a customer twice.
# Flask
from flask import Flask, request, jsonify
from ekstrapay import EkstraPay, SignatureError
app = Flask(__name__)
pay = EkstraPay(KEY, SECRET)
@app.post("/callbacks/ekstrapay/deposit")
def deposit_callback():
try:
event = pay.parse_callback(request.get_json(force=True))
except SignatureError:
return jsonify({"status": False, "message": "Invalid hash"}), 400
# the same callback can arrive twice — keep this idempotent
if event.is_approved:
credit_customer(event.customer_id, event.request_final_amount)
else:
record_status(event.transaction_id, event.status)
return jsonify(pay.callback_response())// Laravel
use EkstraPay\EkstraPay;
use EkstraPay\SignatureException;
public function depositCallback(Request $request)
{
$pay = app(EkstraPay::class);
try {
$event = $pay->parseCallback($request->all());
} catch (SignatureException $e) {
return response()->json(['status' => false, 'message' => 'Invalid hash'], 400);
}
// the same callback can arrive twice — keep this idempotent
if ($event->isApproved()) {
$this->creditCustomer($event->customerId, $event->requestFinalAmount);
} else {
$this->recordStatus($event->transactionId, $event->status);
}
return response()->json(EkstraPay::callbackResponse());
}// Express
const express = require('express');
const { EkstraPay, SignatureError } = require('./ekstrapay');
const app = express();
app.use(express.json());
const pay = new EkstraPay(KEY, SECRET);
app.post('/callbacks/ekstrapay/deposit', (req, res) => {
let event;
try {
event = pay.parseCallback(req.body);
} catch (err) {
if (err instanceof SignatureError) {
return res.status(400).json({ status: false, message: 'Invalid hash' });
}
throw err;
}
// the same callback can arrive twice — keep this idempotent
if (event.isApproved) {
creditCustomer(event.customerId, event.requestFinalAmount);
} else {
recordStatus(event.transactionId, event.status);
}
res.json(EkstraPay.callbackResponse());
});// ASP.NET Core
[ApiController]
[Route("callbacks/ekstrapay")]
public class EkstraPayCallbackController : ControllerBase
{
private readonly EkstraPayClient _pay;
public EkstraPayCallbackController(EkstraPayClient pay) => _pay = pay;
[HttpPost("deposit")]
public IActionResult Deposit([FromBody] JsonNode payload)
{
CallbackEvent evt;
try
{
evt = _pay.ParseCallback(payload);
}
catch (SignatureException)
{
return BadRequest(new { status = false, message = "Invalid hash" });
}
// the same callback can arrive twice — keep this idempotent
if (evt.IsApproved)
CreditCustomer(evt.CustomerId, evt.RequestFinalAmount);
else
RecordStatus(evt.TransactionId, evt.Status);
return Ok(EkstraPayClient.CallbackResponse());
}
}{
"id": 35,
"mode": "deposit",
"transaction_id": "b2843154-178f-4163-914e-e9afd1a83708",
"order_id": "order_id_123",
"customer_fullname": "test test",
"customer_username": "test_user_1a",
"customer_id": "a073006f-cd43-4872-a90b-0dbc8e6cd76d",
"customer_description": null,
"customer_hash": null,
"hash": "",
"customer_site": "TEST 3",
"request_amount": 250,
"request_final_amount": 250,
"status": {
"value": "PAY_REJECTED",
"label": "Reddedildi"
},
"on_process_date": null,
"last_confirmed_date": null,
"last_canceled_date": "2025-11-26 14:54:02",
"created_at": "2025-11-26 14:51:30"
}
// mode tells you deposit or withdraw
// status is an object — branch on status.value
// the client exposes it directly as event.statusTransaction statuses
Every code you can receive from /payment/check, /payment/cancel or a callback. Final statuses never change again and produce no further callbacks.
| Code | Meaning | Final | Cancellable |
|---|---|---|---|
| PAY_NEW | Created, waiting to be processed | no | yes |
| PAY_PROCESSING | Being processed | no | yes |
| PAY_APPROVED | Completed and approved | yes | no |
| PAY_REJECTED | Rejected by the system or the provider | yes | no |
| PAY_CANCELLED | Cancelled by the user or the system | yes | no |
| PAY_TIMEOUT | Expired | yes | no |
| NOT_FOUND | No such transaction for your API service | — | no |
TransactionStatus.APPROVED # "PAY_APPROVED"
TransactionStatus.label("PAY_NEW") # "New"
TransactionStatus.is_final(code)
TransactionStatus.is_cancellable(code)TransactionStatus::APPROVED; // "PAY_APPROVED"
TransactionStatus::label('PAY_NEW'); // "New"
TransactionStatus::isFinal($code);
TransactionStatus::isCancellable($code);TransactionStatus.APPROVED; // "PAY_APPROVED"
TransactionStatus.label('PAY_NEW'); // "New"
TransactionStatus.isFinal(code);
TransactionStatus.isCancellable(code);TransactionStatus.Approved; // "PAY_APPROVED"
TransactionStatus.Label("PAY_NEW"); // "New"
TransactionStatus.IsFinal(code);
TransactionStatus.IsCancellable(code);Signature lab
When a signature is rejected, the fastest way to find the cause is to put your string next to the one the server builds. Enter your own values — the empty-field stripping, the compact serialization and the HMAC all run in your browser, step for step, exactly as the client does them. Nothing is sent anywhere.
If your own output differs, the difference is almost always in the cleaned JSON line: an escaped non-ASCII character, an empty field that survived, or stray whitespace.
Pitfalls
The clients handle all of these. Keep the list if you ever write your own.
Only "success" means created
No other value counts, and a populated data object does not override it. Gate on created, then show the API's message (or description) to the customer whenever the gate closes — it is written in their language and names the real reason.
Send the string you signed
Do not hand the object to your HTTP library and let it serialize again. The json= parameter in requests and a second JSON.stringify can both produce different bytes. Send the signed string as the raw body.
Do not escape non-ASCII characters
Python needs ensure_ascii=False, PHP needs JSON_UNESCAPED_UNICODE, .NET needs UnsafeRelaxedJsonEscaping. If ş is written as \u015f, the two sides hash different byte sequences. JavaScript already behaves correctly.
Strip empty fields on your side too
Fields whose value is null or an empty string are removed before signing. Zero is not empty and stays.
Timestamps are in seconds
Date.now() returns milliseconds — divide by 1000. A millisecond timestamp fails server-side validation every time.
Float formatting in the callback hash. PHP renders 250.00 as 250 when joining, while Python's str(250.0) gives 250.0. The Python and C# clients normalize this. Roll your own and hash verification will fail silently on every callback until you do the same.
Slashes are a known ambiguity
PHP's json_encode escapes / as \/ unless JSON_UNESCAPED_SLASHES is set; the other three languages never escape it. All four clients leave slashes bare, matching the reference Postman script. If a payload containing a slash is rejected, set escape_slashes to true and try again.
A new nonce for every request
Nonces are single-use server-side. Never cache or reuse one, and never reuse a timestamp from an earlier request.
Errors that raise, and errors that do not
| Condition | Behaviour |
|---|---|
| HTTP 400 with a business answer | Returned as a result — read outcome or status_value |
| Transport failure or non-JSON body | NetworkError |
| Callback hash mismatch | SignatureError |
| Required field missing locally | ValidationError |
Field reference
Deposit body
| Field | Type | Required | Description |
|---|---|---|---|
| operation | string | yes | Set by the client: deposit |
| amount | number | yes | Deposit amount |
| customer_fullname | string | yes | Customer's full name |
| customer_username | string | yes | Username on your platform |
| customer_id | string | yes | Unique customer identifier in your system |
| customer_site | string | no | Platform or site identifier |
| customer_email | string | no | Email address |
| customer_phone | string | no | Phone number |
| customer_gender | string | no | M / F |
| customer_description | string | no | Notes — included in the callback hash |
| customer_hash | string | no | Your own verification value, echoed back |
| order_id | string | no | Your order ID; also accepted by /payment/cancel |
Withdraw body
Everything above, with operation set to withdraw, plus:
| Field | Type | Required | Description |
|---|---|---|---|
| account_iban | string | yes | Destination IBAN. The TR58…0015 52 used throughout this guide is a test account — never use it in production. |
| account_holder | string | yes | Name of the IBAN holder |
Check and cancel body
| Field | Type | Required | Description |
|---|---|---|---|
| operation | string | yes | deposit or withdraw; cancel accepts deposit only |
| transaction_id | string | yes | ID returned at creation, or your own order_id |
Callback fields
| Field | Type | Description |
|---|---|---|
| id | integer | EkstraPAY's internal record ID |
| mode | string | deposit or withdraw |
| transaction_id | string | Use it as your idempotency key |
| order_id | string? | The order ID you submitted, if any |
| status | object | { value, label } — branch on value |
| request_amount | number | Amount originally requested |
| request_final_amount | number | Final settled amount |
| hash | string | SHA-256 verification hash |
| customer_* | string? | The customer fields you submitted at creation |
| on_process_date | string? | When processing started |
| last_confirmed_date | string? | Last approval time |
| last_canceled_date | string? | Last cancellation or rejection time |
| created_at | string | Creation time, Y-m-d H:i:s |