Open in Postman Download SDK

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.

signature payload
apiKey|timestamp|nonce|jsonString
apiKeyThe key issued to you
timestampUnix time, in seconds
nonce32 random hex characters
jsonStringCompact body, empty fields dropped

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.

Download

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

Python 3.8+ · requires requests

PHP

PHP 7.4+ · ext-curl, ext-json

Node.js

Node 18+ · no npm packages

C# / ASP.NET

.NET 6+ · no NuGet packages

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.

Open in Postman

What the client covers

OperationEndpointMethod
Create depositPOST /api/v1/payment/createcreateDeposit
Create withdrawPOST /api/v1/payment/createcreateWithdraw
Check transactionPOST /api/v1/payment/checkcheck
Cancel transactionPOST /api/v1/payment/cancelcancel
Verify a callbackinboundparseCallback
Quickstart

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.

ekstrapay.py EkstraPay.php ekstrapay.js EkstraPay.cs
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 customer
use 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
}
Authentication

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.

  1. Take the Unix timestamp in seconds.
  2. Generate a 32-character hex nonce.
  3. Drop every field that is null or an empty string.
  4. Serialize to compact JSON, without escaping non-ASCII characters.
  5. Join with pipes: apiKey|timestamp|nonce|jsonString.
  6. HMAC-SHA256 it with your API secret, lowercase hex.
  7. 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.

HeaderValue
X-API-KeyYour API key
X-TimestampUnix timestamp used in the signature
X-NonceHex nonce used in the signature
X-SignatureHMAC-SHA256 hex digest, lowercase
Content-Typeapplication/json
signing, by hand
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();
Endpoint

Create deposit

POST /api/v1/payment/create

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.

outcomestatusMeaning
newsuccessTransaction created. Show the bank details.
on_processerrorThe customer already has an open transaction. Nothing new was created — show message and keep them on the pending one.
updateerrorA different amount was submitted, so the open transaction was updated instead. Still not a new transaction — show message.
errorerrorValidation 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.

create deposit
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"
}
Endpoint

Create withdraw

POST /api/v1/payment/create

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.

create withdraw
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"
}
Endpoint

Check transaction

POST /api/v1/payment/check

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.

check transaction
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"
}
Endpoint

Cancel transaction

POST /api/v1/payment/cancel

Cancels an open deposit. Your own order_id is accepted in place of the transaction ID.

Current statusCancellableResult
PAY_NEWYesCancelled, credit refunded in full
PAY_PROCESSINGYesCancelled, credit refunded in full
PAY_APPROVEDNonot_cancellable
PAY_REJECTEDNonot_cancellable
PAY_CANCELLEDNonot_cancellable
PAY_TIMEOUTNonot_cancellable
  • Deposits only. Sending withdraw returns an error, so the client always sends deposit.
  • Transactions under review cannot be cancelled. Contact support instead.
  • Credit refund. In credit mode the full request_amount returns 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

SituationMessage
Not found for your API serviceİşlem bulunamadı
Status already finalBu işlem iptal edilemez…
Under reviewBu işlem incelemeye alınmıştır…
Another operation in progressBu işlem şu an işleniyor…
Operation was withdrawÇekim işlemleri API üzerinden iptal edilemez
API service inactive — 401Api durumu
IP not whitelisted — 401Ip adresi engellendi
cancel transaction
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.

Events

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_idcustomer_fullname
customer_usernamecustomer_id
customer_descriptionrequest_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.

callback handler
# 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.status
Events

Transaction statuses

Every code you can receive from /payment/check, /payment/cancel or a callback. Final statuses never change again and produce no further callbacks.

CodeMeaningFinalCancellable
PAY_NEWCreated, waiting to be processednoyes
PAY_PROCESSINGBeing processednoyes
PAY_APPROVEDCompleted and approvedyesno
PAY_REJECTEDRejected by the system or the provideryesno
PAY_CANCELLEDCancelled by the user or the systemyesno
PAY_TIMEOUTExpiredyesno
NOT_FOUNDNo such transaction for your API serviceno
helpers
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);
Debugging

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.

Reference

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

ConditionBehaviour
HTTP 400 with a business answerReturned as a result — read outcome or status_value
Transport failure or non-JSON bodyNetworkError
Callback hash mismatchSignatureError
Required field missing locallyValidationError
Reference

Field reference

Deposit body

FieldTypeRequiredDescription
operationstringyesSet by the client: deposit
amountnumberyesDeposit amount
customer_fullnamestringyesCustomer's full name
customer_usernamestringyesUsername on your platform
customer_idstringyesUnique customer identifier in your system
customer_sitestringnoPlatform or site identifier
customer_emailstringnoEmail address
customer_phonestringnoPhone number
customer_genderstringnoM / F
customer_descriptionstringnoNotes — included in the callback hash
customer_hashstringnoYour own verification value, echoed back
order_idstringnoYour order ID; also accepted by /payment/cancel

Withdraw body

Everything above, with operation set to withdraw, plus:

FieldTypeRequiredDescription
account_ibanstringyesDestination IBAN. The TR58…0015 52 used throughout this guide is a test account — never use it in production.
account_holderstringyesName of the IBAN holder

Check and cancel body

FieldTypeRequiredDescription
operationstringyesdeposit or withdraw; cancel accepts deposit only
transaction_idstringyesID returned at creation, or your own order_id

Callback fields

FieldTypeDescription
idintegerEkstraPAY's internal record ID
modestringdeposit or withdraw
transaction_idstringUse it as your idempotency key
order_idstring?The order ID you submitted, if any
statusobject{ value, label } — branch on value
request_amountnumberAmount originally requested
request_final_amountnumberFinal settled amount
hashstringSHA-256 verification hash
customer_*string?The customer fields you submitted at creation
on_process_datestring?When processing started
last_confirmed_datestring?Last approval time
last_canceled_datestring?Last cancellation or rejection time
created_atstringCreation time, Y-m-d H:i:s