DESARROLLADORES

Direct API (v4)

Recoge la tarjeta en tu propio formulario de pago y envíala a Manuell desde tu servidor. El cliente confirma el pago con 3-D Secure y recibes el resultado por webhook.

  • URL basehttps://www.manuellsolutions.com/api/v4
  • Versión de la APIv4

Empezar por la visión general

Entorno

Prueba sin mover dinero real.

Pagos reales. Se requiere una clave de producción.

Visión general

Tu servidor habla directamente con Manuell: pregunta qué flujo necesita una tarjeta, crea el pedido con los datos de la tarjeta y lo sigue hasta que se paga o falla.

URL base
https://www.manuellsolutions.com/api/v4
Moneda
Solo TRY. Un pago por pedido, sin cuotas.
Importes
Cadenas con 2 decimales, p. ej. "1500.00".
Entorno
sandbox o live, en la cabecera X-CEK-Environment. Debe coincidir con el entorno de tu clave.
Errores
application/problem+json (RFC 7807) con un code de 7 dígitos.
Límite de tasa
300 solicitudes por minuto y clave (600 por minuto y dirección IP antes de la autenticación). Al superarlo: 429 con Retry-After.
Duplicados
No existe Idempotency-Key. merchant_order_id protege frente a pedidos duplicados.
  1. Consulta el BIN

    Envía los 6 primeros dígitos de la tarjeta y el importe a GET /api/v4/bins/{bin}. payment_mode indica si debes recoger la tarjeta tú mismo (direct) o enviar al cliente a una página alojada (hosted).

  2. Crea el pedido

    Envía POST /api/v4/orders con la tarjeta (direct) o solo con el BIN (hosted). El pedido vuelve como requires_action, requires_hosted, pending o failed.

  3. Termina y confirma

    Lleva al cliente a redirect_url (3-D Secure) o a hosted_url. El resultado final llega por webhook; si se retrasa, consulta el pedido con GET /api/v4/orders/{merchantOrderId}.

Configuración en el panel

  • Genera una clave API y su secreto para cada entorno en “API Key e Integración”. Guarda el secreto en tu servidor.
  • Añade las IP públicas de tus servidores a la lista de IP permitidas del entorno. Mientras la lista esté vacía, cada llamada se rechaza con 403 y el código 0055501.
  • Define una dirección de webhook HTTPS. Su host debe ser público.
  • Define las direcciones de retorno de éxito y de fallo (HTTPS en producción). Sin ellas, el cliente se queda en la pantalla de resultado tras pagar.
  • Tu código de comercio es MRC- seguido de tu número de cuenta de 6 dígitos, por ejemplo MRC-123456.

Autenticación

Cada llamada lleva cabeceras X-CEK-*. La firma es un HMAC-SHA256 de una cadena que une la marca de tiempo, el método, la ruta y el hash del cuerpo.

Cabeceras obligatorias

CabeceraDescripción
X-CEK-Api-KeyLa clave API del entorno.
X-CEK-Environmentsandbox o live. Debe coincidir con el entorno de la clave.
X-CEK-Merchant-CodeTu código de comercio, por ejemplo MRC-123456. Obligatorio en sandbox; en producción, si lo envías, debe coincidir con tu cuenta.
X-CEK-TimestampHora Unix actual en segundos (10 dígitos). Una diferencia de más de 300 segundos se rechaza.
X-CEK-Signature64 caracteres hex en minúsculas, calculados como se describe abajo.
Content-Typeapplication/json en las solicitudes POST.

Cómo firmar una solicitud

  1. Toma el cuerpo exactamente como lo envías y calcula sha256(body) en hex minúsculas. Para una solicitud sin cuerpo usa el hash de la cadena vacía.

  2. Une la marca de tiempo, el método en mayúsculas, la ruta y el hash del cuerpo con saltos de línea (\n). La ruta es la ruta completa sin host ni cadena de consulta, por ejemplo /api/v4/orders.

  3. Calcula el HMAC-SHA256 de esa cadena con tu secreto de API como clave y escríbelo como 64 caracteres hex en minúsculas.

  4. Envíala como X-CEK-Signature junto con las demás cabeceras.

Cadena de firma canónica

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

Firma exactamente los bytes que envías: serializa el JSON una sola vez y usa la misma cadena para el hash y para el cuerpo de la solicitud.

Ejemplo (con datos ficticios)

Todos los valores son ficticios: secreto sk_test_EXAMPLE, marca de tiempo 1790000000. Usa los valores de tu propio panel.

Cuerpo de la solicitud

{"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

Código de ejemplo

Cada ejemplo consulta un BIN y crea un pedido en sandbox. Las credenciales salen de variables de entorno; nunca pongas el secreto en el código. Cada ejecución usa un merchant_order_id nuevo.

#!/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"))

Consultar un BIN

GET/api/v4/bins/{bin}

Consulta el BIN de una tarjeta

Llámalo en cuanto el cliente haya escrito los 6 primeros dígitos. La respuesta es una instantánea que te ayuda a elegir entre tu propio formulario de tarjeta y la página alojada; la decisión final se toma al crear el pedido.

Parámetros

CampoTipoObligatorioDescripción
binstring · pathObligatorioLos seis primeros dígitos del número de tarjeta.
amountstring · queryObligatorioImporte del pedido en TRY con 2 decimales. La elegibilidad se evalúa para este importe.

Respuesta · 200

CampoTipoDescripción
binstringEl BIN consultado.
bank_namestring | nullBanco emisor, o null si se desconoce.
networkstring | nullRed de la tarjeta: visa, mastercard, amex, troy o null.
card_typestring | nullcredit, debit, prepaid o null.
card_programstring | nullNombre del programa de la tarjeta, o null.
countrystring | nullPaís emisor (ISO alfa-2), o null.
domesticbooleantrue si la tarjeta se emitió en Turquía.
payment_modestringdirect: recoge la tarjeta tú mismo. hosted: usa la página alojada.

Respuesta · Ejemplo

{
  "data": {
    "bin": "435509",
    "bank_name": "Example Bank",
    "network": "visa",
    "card_type": "credit",
    "card_program": null,
    "country": "TR",
    "domestic": true,
    "payment_mode": "direct"
  }
}
  • Con direct, recoge todos los datos de la tarjeta y crea el pedido con card.
  • Con hosted, crea el pedido solo con bin y lleva al cliente a la hosted_url devuelta.

Errores posibles

Crear un pedido

POST/api/v4/orders

Crea un pedido

Un solo endpoint crea el pedido en ambos modos. Lo decide el cuerpo: envía card para un pedido direct o solo bin para uno hosted. Enviar ambos devuelve 422.

Pedido directo (con tarjeta)

CampoTipoObligatorioDescripción
merchant_order_idstringObligatorioTu referencia del pedido: de 1 a 64 caracteres de A-Z a-z 0-9 . _ -, única por cuenta y entorno.
amountstringObligatorioImporte en TRY con 2 decimales, por ejemplo "1500.00".
card.holder_namestringObligatorioNombre en la tarjeta, de 2 a 120 caracteres.
card.numberstringObligatorioNúmero de tarjeta, de 13 a 19 dígitos, solo cifras.
card.expiry_monthstringObligatorioMes de caducidad, de "01" a "12".
card.expiry_yearstringObligatorioAño de caducidad con 2 dígitos, por ejemplo "30".
card.cvvstringObligatorioCódigo de seguridad, 3 o 4 dígitos.

Cuerpo de la solicitud · Ejemplo

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

Errores posibles

Pedido hosted (solo BIN)

CampoTipoObligatorioDescripción
merchant_order_idstringObligatorioTu referencia del pedido: de 1 a 64 caracteres de A-Z a-z 0-9 . _ -, única por cuenta y entorno.
amountstringObligatorioImporte en TRY con 2 decimales, por ejemplo "1500.00".
binstringObligatorioLos seis primeros dígitos del número de tarjeta.

Cuerpo de la solicitud · Ejemplo

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

Errores posibles

Reglas

  • merchant_order_id debe ser único por cuenta y entorno. Es la única protección frente a duplicados.
  • Repetir una solicitud con el mismo merchant_order_id y el mismo contenido no inicia un pago nuevo y devuelve el pedido tal como está. Un contenido distinto devuelve 409 con el código 0055503.
  • Un campo que no está en el esquema devuelve 422 con el código 1000001.
  • Con una respuesta 4xx no se cobró nada. Usa un merchant_order_id nuevo en el siguiente intento. Un pedido failed también es definitivo.
  • Tras una respuesta 5xx o un tiempo de espera agotado, el pago puede haber empezado ya. No crees un pedido nuevo: repite la misma solicitud sin cambios o consulta el pedido con GET. Deja al menos 30 segundos antes de dar el tiempo por agotado.
  • Si en ese momento el BIN es hosted, una solicitud con card también devuelve requires_hosted y no se usan los datos de la tarjeta.

El objeto del pedido

Las llamadas de creación y lectura devuelven el pedido dentro de data.

CampoTipoDescripción
order_codestringNúmero de pedido, 9 dígitos como cadena. Es el valor que llega en la dirección de retorno.
merchant_order_idstringTu merchant_order_id.
statusstringrequires_action, requires_hosted, pending, paid o failed.
amountstringImporte del pedido, 2 decimales.
currencystringSiempre TRY.
redirect_urlstring | nullDirección de 3-D Secure. Presente mientras el estado sea requires_action.
hosted_urlstring | nullDirección de la página alojada. Presente mientras el estado sea requires_hosted.
hosted_expires_atstring | nullMomento en que hosted_url deja de funcionar (ISO 8601, UTC).
failure_reason_codestring | nullCódigo de motivo de un pedido failed; consulta Motivos de rechazo. Puede ser null si nunca se intentó el pago.
failure_messagestring | nullTexto corto para el cliente sobre el motivo. Siempre está en turco; ramifica según el código.
cardobject | nullResumen de la tarjeta (holder_name, bin, last_four, network, program, issuer_bank_name). Disponible de inmediato en direct; en hosted, cuando se conoce el resultado.
created_atstringHora de creación (ISO 8601, +00:00).
completed_atstring | nullHora de finalización, o null mientras el pedido siga abierto.

Respuesta · Ejemplo

{
  "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
  }
}

Estados y plazos

Campo¿Definitivo?Descripción
requires_actionNoLleva al cliente a redirect_url para 3-D Secure.
requires_hostedNoLleva al cliente a hosted_url.
pendingNoEl banco aún no ha respondido o el cliente abandonó la pantalla. Espera el webhook.
paidSíEl pago se cobró.
failedSíEl pago no se realizó. failure_reason_code explica el motivo.

El cliente dispone de 10 minutos para 3-D Secure. La página alojada funciona hasta hosted_expires_at. Un pago al que el banco nunca responde pasa a paid o failed en 24 horas, y un pago caducado pasa a failed.

En raras ocasiones, un pedido failed puede pasar a paid más tarde. Considera paid como la verdad.

Consultar un pedido

GET/api/v4/orders/{merchantOrderId}

Consulta un pedido

Devuelve el mismo objeto que la creación, sin cuerpo de solicitud. redirect_url se devuelve hasta que termina la ventana de 3-D Secure y hosted_url hasta hosted_expires_at.

Parámetros

CampoTipoObligatorioDescripción
merchantOrderIdstring · pathObligatorioEl merchant_order_id que usaste al crear el pedido.

El webhook es el canal principal. Si se retrasa, consulta el pedido como mucho cada 30 segundos.

Errores posibles

3-D Secure y la página alojada

Tras crear el pedido, el cliente termina el pago en una página que no es tuya. Envíalo allí con una redirección normal de página completa.

3-D Secure (`requires_action`)

  1. Redirige el navegador del cliente a redirect_url.

  2. El cliente confirma el pago con su banco.

  3. Cuando el pago es definitivo, el cliente va a tu dirección de retorno y recibes el webhook.

Página alojada (`requires_hosted`)

  1. Lleva al cliente a hosted_url. Una redirección de página completa siempre funciona; mostrarla en un marco modal depende del permiso de la página, así que mantén la redirección como alternativa.

  2. El cliente introduce la tarjeta y completa 3-D Secure en esa página. Un intento fallido no cierra el pedido; el cliente puede volver a intentarlo allí.

  3. Si el cliente cierra la página, la misma hosted_url la vuelve a abrir hasta hosted_expires_at.

  4. Cuando termina el pago, toda la página se envía a tu dirección de retorno. Confirma el resultado con el webhook.

Dirección de retorno

Cuando un pedido pasa a paid o failed, el cliente va a la dirección de retorno correspondiente que definiste en el panel. Si hay una dirección de webhook, la dirección recibe estos parámetros firmados.

CampoTipoDescripción
order_codestringEl número de pedido de 9 dígitos. La dirección no incluye merchant_order_id; asocia el pedido con este valor.
statusstringpaid o failed.
failure_codestringCódigo de motivo, solo en un pedido failed que lo tenga. En otro caso el parámetro no aparece.
tsintegerHora Unix en segundos en que se construyó la dirección.
key_versionintegerVersión de tu secreto. Aumenta cada vez que lo renuevas.
sigstringFirma, 64 caracteres hex en minúsculas.

Cadena de firma canónica

sig = hex(HMAC-SHA256(key = API_SECRET, message = ts + "." + order_code + "." + status + "." + failure_code))
  • Verifica sig antes de fiarte de los parámetros. Si falta failure_code, usa una cadena vacía en su lugar; el punto se mantiene.
  • Rechaza un ts de más de 10 minutos.
  • El retorno solo indica dónde está el cliente. El resultado es definitivo cuando llega el webhook o lo confirma GET /api/v4/orders/{merchantOrderId}.
  • Si no hay dirección de webhook, la dirección se abre sin estos parámetros.

Ejemplo

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

Webhook

Los resultados se entregan como solicitudes POST JSON firmadas a la dirección de webhook HTTPS del entorno.

EventoCuándo se envía
checkout.order.succeededEl pedido está paid.
checkout.order.failedEl pedido está failed.

Cabecera

CabeceraDescripción
X-CEK-TimestampHora Unix en segundos. Rechaza una diferencia de más de 300 segundos.
X-CEK-SignatureFirma: hex del HMAC-SHA256 de la marca de tiempo, un punto y el cuerpo sin procesar, con tu secreto de API como clave.
X-CEK-Event-IdUUID del evento. Igual que event_id en el cuerpo y no cambia en los reintentos.
X-CEK-Environmentsandbox o live.

Verifica cada notificación

  1. Lee el cuerpo sin procesar antes de analizar el JSON.

  2. Calcula la firma sobre X-CEK-Timestamp, un punto y el cuerpo sin procesar, y compárala con X-CEK-Signature en tiempo constante.

  3. Rechaza la solicitud si la marca de tiempo difiere de tu reloj en más de 300 segundos.

  4. Deduplica con X-CEK-Event-Id. La entrega es al menos una vez y puede llegar desordenada. Si has visto checkout.order.succeeded de un pedido, ignora cualquier checkout.order.failed del mismo.

  5. Responde con cualquier 2xx en 10 segundos (3 para conectar). Las redirecciones (3xx), 4xx, 5xx y los tiempos de espera cuentan como fallo y se reintentan.

Ejemplos de verificación de firma

<?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.

Reintentos

Una entrega fallida se repite tras las esperas de abajo, contadas desde el fallo anterior, hasta 11 intentos en total.

IntentoEspera previa
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)

Cuerpo

El cuerpo incluye schema_version (3), event_id, event_type, un objeto order, un objeto merchant y el resumen de la tarjeta en payment_details.card. Un evento failed añade failure_code, failure_reason_code y failure_message. Ignora los campos que no uses; pueden aparecer campos nuevos.

Cuerpo de la solicitud · Ejemplo

{
  "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."
}

Motivos de rechazo

failure_reason_code es uno de estos códigos. Basa tu lógica en el código; failure_message es un texto en turco para el cliente.

CódigoDescripción
payment_declinedEl banco emisor no aprobó el pago.
do_not_honorEl banco rechazó sin dar un motivo concreto.
insufficient_fundsEl saldo o el límite de la tarjeta no alcanza para este pago.
card_limit_reachedLa tarjeta alcanzó su límite de operaciones.
card_details_invalidNo se aceptó el número de tarjeta.
card_expiry_invalidLa fecha de caducidad no es válida.
card_security_code_invalidEl código de seguridad (CVV) es incorrecto.
card_not_activeLa tarjeta no está habilitada para pagos.
card_brand_not_supportedEsta marca de tarjeta no es compatible con este pago.
three_ds_failedNo se pudo completar 3-D Secure.
security_declinedUn control de seguridad detuvo el pago. Indica al cliente solo que el banco no lo aprobó.
payment_not_permittedEsta tarjeta no se puede usar para este pago.
amount_not_allowedEl importe está fuera del rango que acepta esta tarjeta.
currency_not_supportedLa moneda no es compatible con este pago.
payment_details_rejectedSe rechazaron los datos del pago.
duplicate_paymentParece que ya se cobró este mismo pago.
cancelled_by_customerEl cliente canceló antes de que terminara el pago.
payment_timed_outSe agotó el tiempo del pago.
bank_unavailableNo se puede contactar con el banco ahora. Inténtalo de nuevo en breve.
bank_requested_retryEl banco pidió repetir el pago.
provider_unavailableNo se pudo completar el pago ahora. Inténtalo de nuevo en breve.

La lista puede crecer. Trata un código que no conozcas como payment_declined. En el webhook, security_declined se informa como payment_declined en failure_code.

Códigos de error

Los errores usan el formato application/problem+json. Ramifica según code; title y detail son para personas.

CampoTipoDescripción
type, title, status, detailstring · integerCampos estándar del formato problem.
instancestringLa ruta de la solicitud.
codestringEl código de error de 7 dígitos de la tabla de abajo.
correlation_idstringIndícalo al contactar con soporte. También se envía como X-Correlation-Id.
metadataobjectdeveloper_detail, retry_class y audit_event: información adicional para depurar.
amount_limitobjectSolo con 0055235: min y max del importe permitido, o null en un límite de volumen.
errorsobjectSolo con 1000001: los campos que no superaron la validación.

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"
  }
}

Las respuestas de error llevan además X-Correlation-Id, X-RateLimit-Limit, X-RateLimit-Remaining y Content-Language; un 429 añade Retry-After.

Las comprobaciones se hacen en este orden: esquema (1000001), importe (0055235), BIN (0055233, 0055230), tarjeta (0055234).

CódigoHTTPSignificado
0055501403La lista de IP permitidas está vacía o la solicitud llega desde una dirección que no figura en ella.
0055502401 / 403Clave, entorno o código de comercio incorrectos, cuenta inactiva o acceso a la Direct API no abierto.
0055504401La marca de tiempo no tiene 10 dígitos o difiere en más de 300 segundos, o la firma no son 64 caracteres hex en minúsculas.
0055505401No se pudo verificar la firma.
1000001422El cuerpo no cumple el esquema: campo que falta, incorrecto o desconocido, o card junto con bin. errors enumera los campos.
1000005429Se superó el límite de tasa. Espera lo indicado en Retry-After.
0055001409La cuenta aún no está lista para cobrar; falta un paso de configuración.
0055002403Los pagos con tarjeta están desactivados para la cuenta.
0055235422El importe no es válido o está fuera de los límites (amount_limit presente), o se alcanzó un límite de volumen diario, semanal o mensual (amount_limit es null).
0055230409No hay un canal de pago disponible para este BIN.
0055233409No se pudo identificar el BIN.
0055210422bin no tiene exactamente seis dígitos.
0055234409El número de tarjeta o la fecha de caducidad no es válido.
0055221409Se alcanzó el límite diario de intentos o de importe de esta tarjeta.
0055231409Esta tarjeta no se puede cobrar en este momento.
0055503409Se usó el mismo merchant_order_id con contenido distinto, o la primera solicitud aún se está procesando.
0055402404No existe ningún pedido con este merchant_order_id.
0055404409Solo el endpoint de resultado de sandbox: el pedido no está ni pending ni requires_hosted.

Sandbox

Sandbox usa los mismos endpoints con una clave de sandbox y X-CEK-Environment: sandbox. No se mueve dinero real.

  • X-CEK-Merchant-Code es obligatorio en sandbox. Se usan las direcciones de webhook y de retorno del entorno sandbox.
  • Usa cualquier fecha de caducidad futura y cualquier CVV de 3 dígitos.
  • Los límites de volumen cuentan solo los pedidos de sandbox.
BINHTTPCódigo de resultado
435509200direct
540061200hosted
9792104090055230
cualquier otro BIN4090055233
Número de tarjetaComportamiento
4355090000044242requires_action. Aprueba la pantalla de prueba de 3-D Secure para llegar a paid.
4355090000005151requires_action. Tras 3-D Secure el pedido pasa a failed con insufficient_funds.
4355090000089999requires_action. Tras 3-D Secure el pedido sigue pending; ciérralo con el endpoint de resultado de sandbox.
4355090000040000failed de inmediato con payment_declined, antes de 3-D Secure.
cualquier otra tarjeta válidarequires_action. En la pantalla de prueba de 3-D Secure eliges el resultado: éxito da paid, fallo da failed (payment_declined), tiempo agotado deja pending.

Un número de tarjeta no válido (Luhn) o una fecha de caducidad pasada devuelve 409 con el código 0055234. Un pedido hosted usa el BIN 540061; en su página de prueba eliges éxito (paid) o fallo (failed con insufficient_funds). Mientras siga en requires_hosted, también puedes cerrarlo con el endpoint de resultado de sandbox.

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

Fija el resultado de un pedido

Cierra un pedido de sandbox que está en pending o, en un pedido hosted, aún espera su página hosted (requires_hosted). El webhook y la dirección de retorno funcionan como en producción.

CampoTipoObligatorioDescripción
resultstringObligatoriopaid o failed (un pedido fallido recibe el motivo payment_declined).

Cuerpo de la solicitud · Ejemplo

{
  "result": "paid"
}

Errores posibles

Habla con tu gestor de cuenta sobre el terminal de producción y la prueba final de extremo a extremo. Contacto