DEVELOPERS

Direct API (v4)

Collect the card on your own payment form and send it to Manuell from your server. The customer confirms the payment with 3-D Secure and you receive the result by webhook.

  • Base URLhttps://www.manuellsolutions.com/api/v4
  • API versionv4

Start with the overview

Environment

Test without moving real money.

Real payments. A live key is required.

Overview

Your server talks to Manuell directly: it asks which flow a card needs, creates the order with the card details and then follows the order until it is paid or failed.

Base URL
https://www.manuellsolutions.com/api/v4
Currency
TRY only. One payment per order, no installments.
Amounts
Strings with 2 decimals, for example "1500.00".
Environment
sandbox or live, sent in X-CEK-Environment. It must match the environment of your key.
Errors
application/problem+json (RFC 7807) with a 7-digit code.
Rate limit
300 requests per minute per key (600 per minute per IP address before authentication). Beyond that: 429 with Retry-After.
Duplicates
There is no Idempotency-Key. merchant_order_id protects against duplicate orders.
  1. Look up the BIN

    Send the first 6 digits of the card and the amount to GET /api/v4/bins/{bin}. payment_mode tells you whether to collect the card yourself (direct) or hand the customer to a hosted page (hosted).

  2. Create the order

    Send POST /api/v4/orders with the card (direct) or only the BIN (hosted). The order comes back as requires_action, requires_hosted, pending or failed.

  3. Finish and confirm

    Send the customer to redirect_url (3-D Secure) or hosted_url. The final result arrives as a webhook; if it is late, read the order with GET /api/v4/orders/{merchantOrderId}.

Set up in the panel

  • Generate an API key and secret for each environment on “API Key & Integration”. Keep the secret on your server.
  • Add the public IP addresses of your servers to the IP allowlist of the environment. While the list is empty, every call is rejected with 403 and code 0055501.
  • Set an HTTPS webhook address. Its host must be public.
  • Set the success and failure return addresses (HTTPS in live). Without them the customer stays on the result screen after paying.
  • Your merchant code is MRC- followed by your 6-digit account number, for example MRC-123456.

Authentication

Every call carries X-CEK-* headers. The signature is an HMAC-SHA256 over a string that joins the timestamp, the method, the path and the hash of the body.

Required headers

HeaderDescription
X-CEK-Api-KeyThe API key of the environment.
X-CEK-Environmentsandbox or live. Must match the environment of the key.
X-CEK-Merchant-CodeYour merchant code, for example MRC-123456. Required in sandbox; in live, if you send it, it must match your account.
X-CEK-TimestampCurrent Unix time in seconds (10 digits). A difference of more than 300 seconds is rejected.
X-CEK-Signature64 lowercase hex characters, computed as described below.
Content-Typeapplication/json on POST requests.

How to sign a request

  1. Take the body exactly as you send it and compute sha256(body) as lowercase hex. For a request without a body use the hash of the empty string.

  2. Join the timestamp, the uppercase method, the path and the body hash with line feeds (\n). The path is the full path without host and query string, for example /api/v4/orders.

  3. Compute HMAC-SHA256 of that string with your API secret as the key, and write it as 64 lowercase hex characters.

  4. Send it as X-CEK-Signature together with the other headers.

Canonical signing string

string_to_sign = X-CEK-Timestamp + "\n" + METHOD + "\n" + path + "\n" + hex(sha256(body))
X-CEK-Signature = hex(HMAC-SHA256(key = API_SECRET, message = string_to_sign))  // 64 lowercase hex characters

Sign the exact bytes you send: serialise the JSON once and use the same string for the hash and for the request body.

Worked example (fake credentials)

All values are made up: secret sk_test_EXAMPLE, timestamp 1790000000. Use the values from your own panel.

Request body

{"merchant_order_id":"ORDER-1001","amount":"1500.00","bin":"540061"}

sha256(body)

3bb9ee51caacfb41bd7a8774f12ff861f97b1fd8cf416a00b5fa92618e8390e7

string_to_sign

1790000000
POST
/api/v4/orders
3bb9ee51caacfb41bd7a8774f12ff861f97b1fd8cf416a00b5fa92618e8390e7

X-CEK-Signature

f721cbcccb6ed8a249ed0e331133686fe5f9dfb227856bbd1b6ff9e0b2a8f348

Sample code

Each sample looks up a BIN and creates an order in sandbox. Credentials come from environment variables; never put the secret in code. Every run uses a new merchant_order_id.

#!/usr/bin/env bash
# export MERCHANT_API_KEY=... MERCHANT_API_SECRET=... MERCHANT_CODE=MRC-123456   (sandbox key)
set -euo pipefail

BASE_URL="https://www.manuellsolutions.com"

signed() {  # signed METHOD PATH [BODY]
  local method="$1" path="$2" body="${3:-}" timestamp sha string_to_sign signature
  timestamp="$(date +%s)"
  sha="$(printf '%s' "$body" | openssl dgst -sha256 -hex | awk '{print $NF}')"
  string_to_sign="$(printf '%s\n%s\n%s\n%s' "$timestamp" "$method" "${path%%\?*}" "$sha")"
  # the secret travels in the environment, not on the command line
  signature="$(printf '%s' "$string_to_sign" | python3 -c 'import hashlib,hmac,os,sys; print(hmac.new(os.environ["MERCHANT_API_SECRET"].encode(), sys.stdin.buffer.read(), hashlib.sha256).hexdigest())')"
  local args=(--silent --show-error --request "$method" "$BASE_URL$path"
    --header "X-CEK-Api-Key: $MERCHANT_API_KEY"
    --header "X-CEK-Environment: sandbox"
    --header "X-CEK-Merchant-Code: $MERCHANT_CODE"
    --header "X-CEK-Timestamp: $timestamp"
    --header "X-CEK-Signature: $signature")
  if [ -n "$body" ]; then args+=(--header "Content-Type: application/json" --data-binary "$body"); fi
  curl "${args[@]}"
  echo
}

# 1) BIN lookup: no body, so the hash is sha256("")
signed GET "/api/v4/bins/435509?amount=1500.00"

# 2) create an order; a new merchant_order_id for every order
ORDER_ID="ORDER-$(date +%s)-$(openssl rand -hex 4)"
signed POST "/api/v4/orders" "{\"merchant_order_id\":\"$ORDER_ID\",\"amount\":\"1500.00\",\"card\":{\"holder_name\":\"TEST CUSTOMER\",\"number\":\"4355090000044242\",\"expiry_month\":\"12\",\"expiry_year\":\"30\",\"cvv\":\"123\"}}"
<?php
$config = [
    'base'   => 'https://www.manuellsolutions.com',
    'key'    => getenv('MERCHANT_API_KEY'),
    'secret' => getenv('MERCHANT_API_SECRET'),
    'code'   => getenv('MERCHANT_CODE'),
    'env'    => 'sandbox',
];

function signedRequest(array $config, string $method, string $path, ?array $payload = null): array
{
    $method = strtoupper($method);
    $signingPath = explode('?', $path, 2)[0];
    // serialise once: the same bytes are hashed and sent
    $body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    $timestamp = (string) time();
    $stringToSign = implode("\n", [$timestamp, $method, $signingPath, hash('sha256', $body)]);
    $signature = hash_hmac('sha256', $stringToSign, $config['secret']);

    $headers = [
        'X-CEK-Api-Key: ' . $config['key'],
        'X-CEK-Environment: ' . $config['env'],
        'X-CEK-Merchant-Code: ' . $config['code'],
        'X-CEK-Timestamp: ' . $timestamp,
        'X-CEK-Signature: ' . $signature,
    ];
    if ($body !== '') {
        $headers[] = 'Content-Type: application/json';
    }

    $ch = curl_init($config['base'] . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 35, // an order can take up to 30 seconds
    ]);
    if ($body !== '') {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    }
    $response = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($response === false) {
        throw new RuntimeException('No response; repeat the same request or read the order with GET.');
    }

    return [$status, json_decode((string) $response, true)];
}

// 1) BIN lookup: no body, so the hash is sha256("")
[$status, $bin] = signedRequest($config, 'GET', '/api/v4/bins/435509?amount=1500.00');
echo $status, ' ', $bin['data']['payment_mode'] ?? '', PHP_EOL;

// 2) create an order; a new merchant_order_id for every order
[$status, $order] = signedRequest($config, 'POST', '/api/v4/orders', [
    'merchant_order_id' => 'ORDER-' . time() . '-' . bin2hex(random_bytes(4)),
    'amount' => '1500.00',
    'card' => ['holder_name' => 'TEST CUSTOMER', 'number' => '4355090000044242', 'expiry_month' => '12', 'expiry_year' => '30', 'cvv' => '123'],
]);
echo $status, ' ', $order['data']['status'] ?? '', PHP_EOL;
const crypto = require("node:crypto");

const BASE_URL = "https://www.manuellsolutions.com";
const { MERCHANT_API_KEY, MERCHANT_API_SECRET, MERCHANT_CODE } = process.env;

async function signedRequest(method, path, payload) {
  method = method.toUpperCase();
  const signingPath = path.split("?", 1)[0];
  // serialise once: the same bytes are hashed and sent
  const body = payload === undefined ? "" : JSON.stringify(payload);
  const timestamp = String(Math.floor(Date.now() / 1000));
  const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
  const stringToSign = [timestamp, method, signingPath, bodyHash].join("\n");
  const signature = crypto.createHmac("sha256", MERCHANT_API_SECRET).update(stringToSign).digest("hex");

  const headers = {
    "X-CEK-Api-Key": MERCHANT_API_KEY,
    "X-CEK-Environment": "sandbox",
    "X-CEK-Merchant-Code": MERCHANT_CODE,
    "X-CEK-Timestamp": timestamp,
    "X-CEK-Signature": signature,
  };
  if (body !== "") headers["Content-Type"] = "application/json";

  // an order can take up to 30 seconds
  const res = await fetch(BASE_URL + path, { method, headers, body: body === "" ? undefined : body, signal: AbortSignal.timeout(35000) });
  return { status: res.status, body: JSON.parse(await res.text()) };
}

(async () => {
  // 1) BIN lookup: no body, so the hash is sha256("")
  const bin = await signedRequest("GET", "/api/v4/bins/435509?amount=1500.00");
  console.log(bin.status, bin.body.data?.payment_mode);

  // 2) create an order; a new merchant_order_id for every order
  const order = await signedRequest("POST", "/api/v4/orders", {
    merchant_order_id: `ORDER-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`,
    amount: "1500.00",
    card: { holder_name: "TEST CUSTOMER", number: "4355090000044242", expiry_month: "12", expiry_year: "30", cvv: "123" },
  });
  console.log(order.status, order.body.data?.status);
})();
import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.request
import uuid

BASE_URL = "https://www.manuellsolutions.com"
API_KEY = os.environ["MERCHANT_API_KEY"]
API_SECRET = os.environ["MERCHANT_API_SECRET"]
MERCHANT_CODE = os.environ["MERCHANT_CODE"]


def signed_request(method, path, payload=None):
    method = method.upper()
    signing_path = path.split("?", 1)[0]
    # serialise once: the same bytes are hashed and sent
    body = json.dumps(payload, separators=(",", ":")) if payload is not None else ""
    timestamp = str(int(time.time()))
    string_to_sign = "\n".join([timestamp, method, signing_path, hashlib.sha256(body.encode()).hexdigest()])
    signature = hmac.new(API_SECRET.encode(), string_to_sign.encode(), hashlib.sha256).hexdigest()

    headers = {
        "X-CEK-Api-Key": API_KEY,
        "X-CEK-Environment": "sandbox",
        "X-CEK-Merchant-Code": MERCHANT_CODE,
        "X-CEK-Timestamp": timestamp,
        "X-CEK-Signature": signature,
    }
    if body:
        headers["Content-Type"] = "application/json"

    request = urllib.request.Request(BASE_URL + path, data=body.encode() or None, headers=headers, method=method)
    try:
        with urllib.request.urlopen(request, timeout=35) as response:  # an order can take up to 30 seconds
            return response.status, json.loads(response.read())
    except urllib.error.HTTPError as error:  # problem+json body
        return error.code, json.loads(error.read())


# 1) BIN lookup: no body, so the hash is sha256("")
status, bin_info = signed_request("GET", "/api/v4/bins/435509?amount=1500.00")
print(status, bin_info.get("data", {}).get("payment_mode"))

# 2) create an order; a new merchant_order_id for every order
status, order = signed_request("POST", "/api/v4/orders", {
    "merchant_order_id": f"ORDER-{int(time.time())}-{uuid.uuid4().hex[:8]}",
    "amount": "1500.00",
    "card": {"holder_name": "TEST CUSTOMER", "number": "4355090000044242", "expiry_month": "12", "expiry_year": "30", "cvv": "123"},
})
print(status, order.get("data", {}).get("status"))

Look up a BIN

GET/api/v4/bins/{bin}

Look up a card BIN

Call it as soon as the customer has typed the first 6 digits. The answer is a snapshot that helps you choose between your own card form and the hosted page; the final decision is made when you create the order.

Parameters

FieldTypeRequiredDescription
binstring · pathRequiredFirst six digits of the card number.
amountstring · queryRequiredOrder amount in TRY with 2 decimals. Eligibility is evaluated for this amount.

Response · 200

FieldTypeDescription
binstringThe BIN you asked about.
bank_namestring | nullIssuing bank, or null if unknown.
networkstring | nullCard network: visa, mastercard, amex, troy or null.
card_typestring | nullcredit, debit, prepaid or null.
card_programstring | nullCard programme name, or null.
countrystring | nullIssuing country (ISO alpha-2), or null.
domesticbooleantrue for a card issued in Türkiye.
payment_modestringdirect: collect the card yourself. hosted: use the hosted page.

Response · Example

{
  "data": {
    "bin": "435509",
    "bank_name": "Example Bank",
    "network": "visa",
    "card_type": "credit",
    "card_program": null,
    "country": "TR",
    "domestic": true,
    "payment_mode": "direct"
  }
}
  • With direct, collect the full card details and create the order with card.
  • With hosted, create the order with only bin and send the customer to the returned hosted_url.

Possible errors

Create an order

POST/api/v4/orders

Create an order

One endpoint creates the order in both modes. The body decides: send card for a direct order, or only bin for a hosted order. Sending both returns 422.

Direct order (with card)

FieldTypeRequiredDescription
merchant_order_idstringRequiredYour order reference: 1–64 characters from A-Z a-z 0-9 . _ -, unique per account and environment.
amountstringRequiredAmount in TRY with 2 decimals, for example "1500.00".
card.holder_namestringRequiredName on the card, 2–120 characters.
card.numberstringRequiredCard number, 13–19 digits, digits only.
card.expiry_monthstringRequiredExpiry month, "01" to "12".
card.expiry_yearstringRequiredExpiry year as 2 digits, for example "30".
card.cvvstringRequiredSecurity code, 3 or 4 digits.

Request body · Example

{
  "merchant_order_id": "ORDER-1001",
  "amount": "1500.00",
  "card": {
    "holder_name": "TEST CUSTOMER",
    "number": "4355090000044242",
    "expiry_month": "12",
    "expiry_year": "30",
    "cvv": "123"
  }
}

Possible errors

Hosted order (BIN only)

FieldTypeRequiredDescription
merchant_order_idstringRequiredYour order reference: 1–64 characters from A-Z a-z 0-9 . _ -, unique per account and environment.
amountstringRequiredAmount in TRY with 2 decimals, for example "1500.00".
binstringRequiredFirst six digits of the card number.

Request body · Example

{
  "merchant_order_id": "ORDER-1002",
  "amount": "1500.00",
  "bin": "540061"
}

Possible errors

Rules

  • merchant_order_id must be unique per account and environment. It is the only protection against duplicates.
  • Repeating a request with the same merchant_order_id and the same content starts no new payment and returns the order as it is now. Different content returns 409 with code 0055503.
  • A field that is not in the schema returns 422 with code 1000001.
  • On a 4xx response no payment was taken. Use a new merchant_order_id for the next attempt. A failed order is final as well.
  • After a 5xx response or a timeout the payment may already have started. Do not create a new order: repeat the same request unchanged or read the order with GET. Allow at least 30 seconds before you time out.
  • If the BIN is hosted at that moment, a request that contains card still returns requires_hosted and the card details are not used.

The order object

Create and read calls return the order inside data.

FieldTypeDescription
order_codestringOrder number, 9 digits as a string. It is the value that comes back on the return address.
merchant_order_idstringYour merchant_order_id.
statusstringrequires_action, requires_hosted, pending, paid or failed.
amountstringOrder amount, 2 decimals.
currencystringAlways TRY.
redirect_urlstring | null3-D Secure address. Set while the status is requires_action.
hosted_urlstring | nullHosted page address. Set while the status is requires_hosted.
hosted_expires_atstring | nullWhen hosted_url stops working (ISO 8601, UTC).
failure_reason_codestring | nullReason code for a failed order; see Decline reasons. Can be null if the payment was never attempted.
failure_messagestring | nullShort customer-facing text for the reason. It is always in Turkish; branch on the code instead.
cardobject | nullCard summary (holder_name, bin, last_four, network, program, issuer_bank_name). Present at once for direct; for hosted once the result is known.
created_atstringCreation time (ISO 8601, +00:00).
completed_atstring | nullCompletion time, or null while the order is open.

Response · Example

{
  "data": {
    "order_code": "123456789",
    "merchant_order_id": "ORDER-1001",
    "status": "requires_action",
    "amount": "1500.00",
    "currency": "TRY",
    "redirect_url": "https://bank.example.com/3ds/123456789",
    "hosted_url": null,
    "hosted_expires_at": null,
    "failure_reason_code": null,
    "failure_message": null,
    "card": {
      "holder_name": "TEST CUSTOMER",
      "bin": "435509",
      "last_four": "4242",
      "network": "visa",
      "program": null,
      "issuer_bank_name": "Example Bank"
    },
    "created_at": "2026-10-13T10:15:02+00:00",
    "completed_at": null
  }
}

requires_hosted

{
  "data": {
    "order_code": "123456789",
    "merchant_order_id": "ORDER-1001",
    "status": "requires_hosted",
    "amount": "1500.00",
    "currency": "TRY",
    "redirect_url": null,
    "hosted_url": "https://pay.example.com/h/123456789",
    "hosted_expires_at": "2026-10-13T10:30:02+00:00",
    "failure_reason_code": null,
    "failure_message": null,
    "card": null,
    "created_at": "2026-10-13T10:15:02+00:00",
    "completed_at": null
  }
}

Statuses and timing

FieldFinal?Description
requires_actionNoSend the customer to redirect_url for 3-D Secure.
requires_hostedNoSend the customer to hosted_url.
pendingNoThe bank has not answered yet, or the customer left the screen. Wait for the webhook.
paidYesThe payment was collected.
failedYesThe payment did not go through. failure_reason_code explains why.

The customer has 10 minutes for 3-D Secure. The hosted page works until hosted_expires_at. A payment the bank never answers becomes paid or failed within 24 hours, and an expired payment becomes failed.

Rarely, a failed order can become paid later. Treat paid as the truth.

Get an order

GET/api/v4/orders/{merchantOrderId}

Get an order

Returns the same object as the create call, without a request body. redirect_url is returned until the 3-D Secure window ends and hosted_url until hosted_expires_at.

Parameters

FieldTypeRequiredDescription
merchantOrderIdstring · pathRequiredThe merchant_order_id you used when creating the order.

The webhook is the main channel. If it is late, read the order at most every 30 seconds.

Possible errors

3-D Secure and the hosted page

After the order is created, the customer finishes the payment on a page that is not yours. Send them there with a normal full-page redirect.

3-D Secure (`requires_action`)

  1. Redirect the customer's browser to redirect_url.

  2. The customer confirms the payment with their bank.

  3. When the payment is final, the customer is sent to your return address and you receive the webhook.

Hosted page (`requires_hosted`)

  1. Send the customer to hosted_url. A full-page redirect always works; showing it in a modal frame depends on the page's permission, so keep the redirect as the fallback.

  2. The customer enters the card and completes 3-D Secure on that page. A failed attempt does not close the order; the customer can try again there.

  3. If the customer closes the page, the same hosted_url opens it again until hosted_expires_at.

  4. When the payment ends, the whole page is sent to your return address. Confirm the result with the webhook.

Return address

When an order becomes paid or failed, the customer is sent to the matching return address you set in the panel. If a webhook address is set, the address gets these signed query parameters.

FieldTypeDescription
order_codestringThe 9-digit order number. The address has no merchant_order_id; match the order with this value.
statusstringpaid or failed.
failure_codestringReason code, only for a failed order that has one. Otherwise the parameter is absent.
tsintegerUnix time in seconds when the address was built.
key_versionintegerVersion of your secret. It goes up each time you renew the secret.
sigstringSignature, 64 lowercase hex characters.

Canonical signing string

sig = hex(HMAC-SHA256(key = API_SECRET, message = ts + "." + order_code + "." + status + "." + failure_code))
  • Verify sig before you trust the parameters. If failure_code is absent, use an empty string in its place; the dot stays.
  • Reject a ts older than 10 minutes.
  • The return only says where the customer is. The result is final when the webhook arrives or GET /api/v4/orders/{merchantOrderId} says so.
  • If no webhook address is set, the address is called without these parameters.

Example

https://shop.example.com/payment/success?order_code=123456789&status=failed&failure_code=insufficient_funds&ts=1790000000&key_version=1&sig=a6d0ee5e1774b836d04836d4046fff37e719904d9fea69e28a558e619743c4c2

Webhook

Results are delivered as signed JSON POST requests to the HTTPS webhook address of the environment.

EventSent when
checkout.order.succeededThe order is paid.
checkout.order.failedThe order is failed.

Header

HeaderDescription
X-CEK-TimestampUnix time in seconds. Reject a difference of more than 300 seconds.
X-CEK-SignatureSignature: hex of the HMAC-SHA256 of the timestamp, a dot and the raw body, with your API secret as the key.
X-CEK-Event-IdUUID of the event. Same as event_id in the body and unchanged on retries.
X-CEK-Environmentsandbox or live.

Verify every notification

  1. Read the raw body before parsing the JSON.

  2. Compute the signature over X-CEK-Timestamp, a dot and the raw body, and compare it with X-CEK-Signature in constant time.

  3. Reject the request if the timestamp differs from your clock by more than 300 seconds.

  4. Deduplicate with X-CEK-Event-Id. Delivery is at least once and can be out of order. If you have seen checkout.order.succeeded for an order, ignore any checkout.order.failed for it.

  5. Answer with any 2xx within 10 seconds (3 seconds to connect). Redirects (3xx), 4xx, 5xx and timeouts count as failures and are retried.

Signature check samples

<?php
// Verify a notification: the signature covers "<X-CEK-Timestamp>.<raw body>".
function isValidDirectWebhook(string $rawBody, string $timestamp, string $signature, string $secret): bool
{
    if (preg_match('/^[0-9]{10}$/D', $timestamp) !== 1 || preg_match('/^[0-9a-f]{64}$/D', $signature) !== 1
        || abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    return hash_equals(hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret), $signature);
}

$rawBody = file_get_contents('php://input');
if (!isValidDirectWebhook($rawBody, $_SERVER['HTTP_X_CEK_TIMESTAMP'] ?? '', $_SERVER['HTTP_X_CEK_SIGNATURE'] ?? '', getenv('MERCHANT_API_SECRET'))) {
    http_response_code(401);
    exit;
}
// Deduplicate with X-CEK-Event-Id, save the event, then answer 2xx.
http_response_code(204);
const crypto = require("node:crypto");

// Verify a notification: the signature covers "<X-CEK-Timestamp>.<raw body>".
// rawBody must be the untouched request bytes (Buffer), not re-serialised JSON.
function isValidDirectWebhook(rawBody, timestamp, signature, secret) {
  if (!/^[0-9]{10}$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)
      || Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(timestamp + "." + rawBody.toString("utf8")).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Then deduplicate with X-CEK-Event-Id, save the event and answer 2xx.
module.exports = { isValidDirectWebhook };
import hashlib
import hmac
import re
import time


def is_valid_direct_webhook(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    """Verify a notification: the signature covers "<X-CEK-Timestamp>.<raw body>"."""
    if (not re.fullmatch(r"[0-9]{10}", timestamp) or not re.fullmatch(r"[0-9a-f]{64}", signature)
            or abs(int(time.time()) - int(timestamp)) > 300):
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


# Then deduplicate with X-CEK-Event-Id, save the event and answer 2xx.

Retries

A failed delivery is repeated after the waits below, counted from the previous failure, up to 11 attempts in total.

AttemptWait before it
1—
260 s (1 min)
3300 s (5 min)
4900 s (15 min)
51800 s (30 min)
63600 s (1 h)
710800 s (3 h)
821600 s (6 h)
943200 s (12 h)
1086400 s (24 h)
11172800 s (48 h)

Body

The body carries schema_version (3), event_id, event_type, an order object, a merchant object and the card summary in payment_details.card. A failed event adds failure_code, failure_reason_code and failure_message. Ignore fields you do not use; new fields can appear.

Request body · Example

{
  "schema_version": 3,
  "event_id": "3BAEB108-41A8-40AE-81EF-0A82AA1132D6",
  "event_type": "checkout.order.failed",
  "order": {
    "code": "123456789",
    "merchant_order_id": "ORDER-1001",
    "status": "failed",
    "amount": "1500.00",
    "currency": "TRY"
  },
  "merchant": {
    "code": "MRC-123456"
  },
  "payment_details": {
    "card": {
      "holder_name": "TEST CUSTOMER",
      "bin": "435509",
      "last_four": "4242",
      "network": "visa",
      "program": null,
      "issuer_bank_code": null,
      "issuer_bank_name": "Example Bank",
      "installment_count": 1,
      "three_d": true
    }
  },
  "failure_code": "payment_declined",
  "failure_reason_code": "insufficient_funds",
  "failure_message": "Kart bakiyeniz veya limitiniz bu ödeme için yeterli değil."
}

Decline reasons

failure_reason_code is one of these codes. Build your logic on the code; failure_message is a Turkish text for the customer.

CodeDescription
payment_declinedThe issuing bank did not approve the payment.
do_not_honorThe bank refused without giving a specific reason.
insufficient_fundsThe balance or limit of the card is too low for this payment.
card_limit_reachedThe card has reached its transaction limit.
card_details_invalidThe card number was not accepted.
card_expiry_invalidThe expiry date is invalid.
card_security_code_invalidThe security code (CVV) is wrong.
card_not_activeThe card is not enabled for payments.
card_brand_not_supportedThis card brand is not supported for this payment.
three_ds_failed3-D Secure could not be completed.
security_declinedA security check stopped the payment. Tell the customer only that the bank did not approve it.
payment_not_permittedThis card cannot be used for this payment.
amount_not_allowedThe amount is outside the range this card accepts.
currency_not_supportedThe currency is not supported for this payment.
payment_details_rejectedThe payment details were rejected.
duplicate_paymentThe same payment seems to have been taken already.
cancelled_by_customerThe customer cancelled before the payment finished.
payment_timed_outThe payment time ran out.
bank_unavailableThe bank cannot be reached right now. Try again shortly.
bank_requested_retryThe bank asked to repeat the payment.
provider_unavailableThe payment could not be completed right now. Try again shortly.

The list can grow. Treat a code you do not know like payment_declined. In the webhook, security_declined is reported as payment_declined in failure_code.

Error codes

Errors use the application/problem+json format. Branch on code; title and detail are for people.

FieldTypeDescription
type, title, status, detailstring · integerStandard problem fields.
instancestringThe path of the request.
codestringThe 7-digit error code from the table below.
correlation_idstringQuote it when you contact support. Also sent as X-Correlation-Id.
metadataobjectdeveloper_detail, retry_class and audit_event: extra information for debugging.
amount_limitobjectOnly on 0055235: min and max of the allowed amount, or null for a volume limit.
errorsobjectOnly on 1000001: the fields that failed validation.

application/problem+json

{
  "type": "https://www.manuellsolutions.com/problems/0055233",
  "title": "BIN not recognised",
  "status": 409,
  "detail": "No card range matches this BIN.",
  "instance": "/api/v4/bins/999999",
  "code": "0055233",
  "correlation_id": "3BAEB108-41A8-40AE-81EF-0A82AA1132D6",
  "metadata": {
    "developer_detail": "bin_not_found",
    "retry_class": "no_retry",
    "audit_event": "bin_lookup_rejected"
  }
}

Error responses also carry X-Correlation-Id, X-RateLimit-Limit, X-RateLimit-Remaining and Content-Language; a 429 adds Retry-After.

Checks run in this order: schema (1000001), amount (0055235), BIN (0055233, 0055230), card (0055234).

CodeHTTPMeaning
0055501403The IP allowlist is empty or the request comes from an address that is not on it.
0055502401 / 403Wrong key, environment or merchant code, an inactive account, or Direct API access that is not open.
0055504401The timestamp is not 10 digits or is more than 300 seconds off, or the signature is not 64 lowercase hex characters.
0055505401The signature could not be verified.
1000001422The body does not match the schema: a missing, wrong or unknown field, or card together with bin. errors lists the fields.
1000005429Rate limit exceeded. Wait for Retry-After.
0055001409The account is not ready to take payments yet; a setup step is missing.
0055002403Card payments are turned off for the account.
0055235422The amount is not valid or is outside the limits (amount_limit is set), or a daily, weekly or monthly volume limit was reached (amount_limit is null).
0055230409No payment channel is available for this BIN.
0055233409The BIN could not be identified.
0055210422bin is not exactly six digits.
0055234409The card number or the expiry date is not valid.
0055221409The daily attempt or amount limit of this card was reached.
0055231409This card cannot be charged at the moment.
0055503409The same merchant_order_id was used with different content, or the first request is still being processed.
0055402404No order exists for this merchant_order_id.
0055404409Sandbox result endpoint only: the order is neither pending nor requires_hosted.

Sandbox

Sandbox uses the same endpoints with a sandbox key and X-CEK-Environment: sandbox. No real money moves.

  • X-CEK-Merchant-Code is required in sandbox. The webhook and return addresses of the sandbox environment are used.
  • Use any future expiry date and any 3-digit CVV.
  • Volume limits count sandbox orders only.
BINHTTPResult code
435509200direct
540061200hosted
9792104090055230
any other BIN4090055233
Card numberWhat happens
4355090000044242requires_action. Approve the test 3-D Secure screen to get paid.
4355090000005151requires_action. After 3-D Secure the order becomes failed with insufficient_funds.
4355090000089999requires_action. After 3-D Secure the order stays pending; close it with the sandbox result endpoint.
4355090000040000failed at once with payment_declined, before 3-D Secure.
any other valid cardrequires_action. On the test 3-D Secure screen you choose the result: success gives paid, failure gives failed (payment_declined), timeout leaves pending.

An invalid card number (Luhn) or a past expiry date returns 409 with code 0055234. A hosted order uses the BIN 540061; on its test page you choose success (paid) or failure (failed with insufficient_funds). While it is still requires_hosted, you can also close it with the sandbox result endpoint.

POST/api/v4/sandbox/orders/{merchantOrderId}/result

Set the result of an order

Closes a sandbox order that is pending or, for a hosted order, still waiting for its hosted page (requires_hosted). The webhook and the return address work as in live.

FieldTypeRequiredDescription
resultstringRequiredpaid or failed (a failed order gets the reason payment_declined).

Request body · Example

{
  "result": "paid"
}

Possible errors

Talk to your account manager about the live terminal and the final end-to-end test. Contact