Skip to content
Documentation

Getting started

Payniko accepts card payments through a single signed REST API. You can either redirect the payer to the Payniko payment page (Hosted API) or collect the card details in your own payment form (Direct API).

Base URL and formats

Base URL
https://payniko.com/api. The path does not include a version.
Format
JSON, UTF-8 encoded. Errors use application/problem+json.
Amounts
Strings with two decimal places, for example "100.00". Never send amounts as JSON numbers.
Currency
USD, EUR, AED or TRY. If currency is omitted, the system main currency applies.
Time
ISO 8601 in UTC with milliseconds, for example 2026-11-01T12:00:00.000Z.
IDs
Payment, refund, withdrawal and dispute IDs use LUVEI-ABCD-123456. Store the complete ID.
Language
Messages are returned in the language set by Accept-Language (en, tr, de, ar, es, el); the default is en. Codes and field names are never translated.

Getting your API keys

New accounts receive Sandbox and Live keys automatically. If you have an account, sign in and open Access keys. If you received an account file, sign in with its username and password; do not register again.

1. Create your account

  1. Create an account. Enter an email address you use and press Send code. Enter the six-digit code from the email on the registration page, then press Verify. If it does not arrive, check the address and spam folder; request another code after the countdown ends.

  2. After email verification, select your country code and enter the phone number linked to your own Telegram account. Press Verify beside it. Verification takes place in Telegram; no SMS is sent.

  3. Open the Telegram link shown on the page, or scan its QR code with your phone if using a computer. Press Start in the bot, then use its share-phone-number button to share your own number. Typing a number as a message or sending someone else’s contact does not verify it. Return to registration and wait for the verified status.

  4. Press Continue to account details. Choose a username and password, repeat the password, complete the confirmations on screen, then press Create account. A username has 4–32 characters: lowercase letters, digits, dots, underscores or hyphens. A password needs at least 12 characters.

  5. Save the recovery code displayed after account creation in a safe place. It is shown only on this screen and is different from an API secret. Confirm you saved it and press I saved the code, continue.

2. Get Sandbox keys and your merchant code

  1. Open Access keys and find the Sandbox card. New accounts already have keys. If an older account has no active key, enter an optional label and press Generate key. No IP registration is needed.

  2. Collect the three values below from the Sandbox card. Use the eye icon in the Secret row to reveal it, or the copy icon to copy it. The account owner can view an active secret again; you do not need to rotate a key just to view it.

  3. Share these values securely with your integration developer or store them in your server’s private configuration. The API key and secret must come from the same Sandbox card. Never put the secret in browser JavaScript, public files or a URL.

ValueWhere to find itWhat it is used for
Merchant codeThe six-digit code on the cardThe X-Merchant-Code header. The same account uses the same code for Sandbox and Live.
API keyThe API key row on the Sandbox cardThe X-Api-Key header. This key determines the environment.
API secretThe eye/copy icons on the same Sandbox cardSigning requests and verifying webhook signatures; it is not sent as a request field.

Creating a test payment

Test your integration in Sandbox without moving real funds. A successful test is not a requirement for Live keys; it is recommended to verify signing, redirects and webhooks.

  1. Use a server example from Request helper functions. Configure it with your Sandbox API key, Sandbox secret and merchant code. Sandbox has no separate API hostname: use the documented base URL; the key determines the environment.

  2. Check the Sandbox amount limits and available currencies under Payment experience. Choose an amount within those limits. For your first successful test, do not use an amount ending in .77: that triggers the pending test scenario.

  3. Fill in the fields in the POST /api/payments/hosted example: a unique merchant_order_id, amount, currency, customer.email / customer.phone, and the three notification/return URLs. Use reachable contact details and a phone number with its country code. webhook_url must be a public HTTPS endpoint on your server; localhost and private network addresses are not accepted even in Sandbox. success_url / fail_url are your browser return URLs.

  4. Send the signed POST /api/payments/hosted request from your server. Use a new order reference and Idempotency-Key for a new test. When retrying the identical request after a connection error, keep the same Idempotency-Key. A 201 response means the payment was created, not that it succeeded.

  5. Open the full next_action.url returned in the response in a browser. On the Sandbox payment form, use test card 4000000000000002, a future expiry date and any three-digit CVV; for example 12/30 and 123. Complete the successful/approved outcome on the test 3D Secure screen. Do not use a real card. Other scenarios are listed under Test cards.

  6. After the browser return, query the payment with GET /api/payments/{payment_id} or process a webhook after verifying its signature. The API status must be succeeded. requires_action / processing are intermediate states; failed does not count as success. Opening a success return page alone is not proof of payment.

  7. Check the test result under Request activity. A successful record completes this integration scenario. Live access uses its own key pair.

Getting your Live keys

Sandbox and Live use separate ready key pairs. Live keys require neither an IP registration nor a successful Sandbox transaction. Changing the panel environment does not change your server configuration.

  1. Open Live access page and find the Live card. A new account already has Live keys; if an older account has none, use Generate key.

  2. Take the API key and secret from that same Live card. The merchant code is the same as in Sandbox. Store the secret only in your server’s private configuration.

  3. Use this Live key pair in your production server configuration instead of the Sandbox pair. The API URL stays the same. Sandbox keys remain valid for testing; simply viewing a card in the panel does not change your server configuration.

  4. Check Live limits and currencies under Payment experience. Your Live keys are now ready. Requests signed with Live keys enter the real payment flow; continue test-card testing with Sandbox keys.

If you get stuck

What you seeWhat to do
Email or Telegram is not verifiedEnter the email code on the registration page. For Telegram, use the bot link from that page and share the number you entered using the bot’s button. Renew an expired link from registration.
Cannot generate a keyCheck that your account is active and API collections are enabled. Adding an IP or completing a Sandbox payment is not required.
Live key is missingOpen the Live card. A new account already has keys; generate a key if an older account has no active one. If it still fails, Contact support.
You forgot to save the API secretUse the eye or copy icon on the active key’s card. Your recovery code is not your API secret.
API collections are not visible in your accountContact support to check your API collections access. Payment-link access and API access can be enabled separately.

Endpoint overview

EndpointPurpose
POST /api/payments/hostedCreate a Hosted API payment · Hosted API
GET /api/payments/{payment_id}Retrieve a payment · Hosted API · Direct API
GET /api/payments/bin/{bin6}Check the BIN (first six card digits) · Direct API
POST /api/payments/directCreate a Direct API payment · Direct API
GET /api/account/balanceAccount balance · Balances
GET /api/account/available-balanceWithdrawable balance · Balances
POST|GET /api/account/refundsRefunds and voids · Refunds
POST|GET /api/account/withdrawalsUSDT withdrawals · Withdrawals
GET /api/account/disputesDisputes and dispute responses · Disputes
GET|POST /api/webhooks/deliveriesWebhook delivery history and resending · Deliveries
POST /api/sandbox/disputesCreate a test dispute (sandbox key only) · Sandbox

All endpoints use the same authentication, and every POST additionally requires an Idempotency-Key.

Authentication

Every request carries your API key and an HMAC-SHA256 signature computed with your API secret.

API key and secret

ValueFormatDescription
API keyapi_ + 32 hexSent in X-Api-Key. The key also determines the environment.
API secret64 hexUsed to sign requests and verify webhook signatures; never sent in a request. The account owner can reveal or copy an active secret again using the eye/copy icons in the panel.
Merchant code[0-9]{6}Your account number, sent in X-Merchant-Code.

Every API key belongs to exactly one environment, sandbox or live, and the key determines the environment in which a request runs: use the sandbox key for testing and the live key for real payments. The X-Environment header is optional; if it is omitted, the request runs in the key's environment. If it is sent, its value must be sandbox or live (case-insensitive) and must match the key's environment; otherwise the request is rejected with HTTP 401 and code 0055502, and nothing is processed. The required authentication headers are X-Api-Key, X-Merchant-Code, X-Timestamp and X-Signature, plus Idempotency-Key on every write request. The environment is not part of the string to sign. Every response body that contains environment (payments, balances, refunds and other resources) reports the environment that was used.

Key rotation

  • Create a new key if the secret may have been exposed or when someone with access to it leaves. The previous key stops working immediately.
  • Webhooks and return links for a payment are signed with the secret of the key that created the payment; this also applies to refund and dispute webhooks sent long after the payment. Retain the previous secret for as long as such events can still arrive.

Request headers

HeaderRequiredValue
X-Api-KeyRequiredYour API key.
X-Merchant-CodeRequiredYour six-digit merchant code.
X-TimestampRequiredCurrent Unix time in seconds (10 digits).
X-SignatureRequiredThe request signature: 64 lowercase hex characters.
Idempotency-KeyConditionalRequired on every POST. See Idempotency-Key.
Content-TypeConditionalapplication/json for requests with a body.
Accept-LanguageOptionalLanguage of error messages: en (default), tr, de, ar, es or el.

Send each X- header exactly once. Header values must not contain commas, line breaks or spaces.

Request signing

The string to sign consists of four values joined by line feeds: the timestamp, the HTTP method, the path and the SHA-256 hash of the body.

String to sign

canonical   = X-Timestamp + "\n" + METHOD + "\n" + path + "\n" + hex(sha256(raw body))
X-Signature = hex(HMAC-SHA256(key = API secret, message = canonical))   // 64 lowercase hex characters
  1. Encode the body as JSON once and hash exactly those bytes with SHA-256 (lowercase hex). For a request without a body, hash the empty string (e3b0c442…b855).

  2. Use the path exactly as it appears in the URL, starting with /api/ and excluding the query string. For example, GET /api/payments/bin/540061?amount=100.00 signs /api/payments/bin/540061.

  3. Compute the HMAC-SHA256 with the API secret as the key. Use the secret string as is; do not decode it.

  4. Send the result in X-Signature, and send exactly the hashed bytes as the request body.

Timestamp tolerance

X-Timestamp must be within 300 seconds of Payniko server time, in either direction. Keep your server clock synchronised with NTP. If the deviation is larger, the request is rejected with 401 0055504, and detail contains the Payniko server time.

Signing example

Use these sample values to test your implementation. With this secret, timestamp and body, your code must produce exactly this signature.

API secret (sample)

e3c4a5b6d7e8f90112233445566778899aabbccddeeff0011223344556677889

Body (exact bytes)

{"merchant_order_id":"ORDER-2026-001","amount":"100.00","currency":"USD","webhook_url":"https://merchant.example/webhooks/payments","success_url":"https://merchant.example/payment/success","fail_url":"https://merchant.example/payment/failure","customer":{"email":"[email protected]","phone":"+37061234567"}}

sha256(body)

dca2bae6e50c13c26b22b57e3c31161ba3174147060fcbf22b2ba99cc13560ae

String to sign

1793534400
POST
/api/payments/hosted
dca2bae6e50c13c26b22b57e3c31161ba3174147060fcbf22b2ba99cc13560ae

X-Signature

5522c27ff0b49ffa4c44712ed5db350c1c5a2cbfc0c568edbce39cff59ca620c

Request headers

POST /api/payments/hosted
X-Api-Key: api_0123456789abcdef0123456789abcdef
X-Merchant-Code: 123456
X-Timestamp: 1793534400
X-Signature: 5522c27ff0b49ffa4c44712ed5db350c1c5a2cbfc0c568edbce39cff59ca620c
Idempotency-Key: 6f1c2a9e-1d2b-4c1a-9f0e-3b7d5a2c8e41
Content-Type: application/json
Accept: application/json

Request helpers

Add one of these helpers to your server. It reads API_KEY, API_SECRET and MERCHANT_CODE from environment variables, signs the request and sends it. The other code examples use this helper.

#!/usr/bin/env bash
# payniko.sh - signed request helper (bash, curl, openssl).
# Usage: ./payniko.sh METHOD PATH [JSON_BODY] [IDEMPOTENCY_KEY]
# Reads API_KEY, API_SECRET and MERCHANT_CODE. The key decides sandbox or live.
# Optional: SANDBOX_SCENARIO adds the X-Sandbox-Scenario header (sandbox keys only).
set -euo pipefail
: "${API_KEY:?set API_KEY}" "${API_SECRET:?set API_SECRET}" "${MERCHANT_CODE:?set MERCHANT_CODE}"
BASE_URL="https://payniko.com"
method="$(printf '%s' "${1:?METHOD}" | tr '[:lower:]' '[:upper:]')"
target="${2:?PATH}"            # may include a query string; only the path is signed
body="${3-}"                   # sent byte-for-byte as hashed
idempotency_key="${4-}"
path="${target%%\?*}"
timestamp="$(date +%s)"
body_hash="$(printf '%s' "$body" | openssl dgst -sha256 | sed 's/^.* //')"
signature="$(printf '%s\n%s\n%s\n%s' "$timestamp" "$method" "$path" "$body_hash" \
  | openssl dgst -sha256 -hmac "$API_SECRET" | sed 's/^.* //')"
args=(-sS --fail-with-body -X "$method" "$BASE_URL$target"
  -H "X-Api-Key: $API_KEY" -H "X-Merchant-Code: $MERCHANT_CODE" -H "X-Timestamp: $timestamp"
  -H "X-Signature: $signature" -H "Accept: application/json" -H "Accept-Language: en")
if [ -n "$body" ]; then args+=(-H "Content-Type: application/json" --data-binary "$body"); fi
if [ -n "$idempotency_key" ]; then args+=(-H "Idempotency-Key: $idempotency_key"); fi
if [ -n "${SANDBOX_SCENARIO-}" ]; then args+=(-H "X-Sandbox-Scenario: $SANDBOX_SCENARIO"); fi
curl "${args[@]}"
echo
// payniko.mjs - signed request helper (Node.js 20+, no dependencies).
import {createHash, createHmac, randomUUID} from 'node:crypto';

const base = 'https://payniko.com';
const key = process.env.API_KEY ?? '';
const secret = process.env.API_SECRET ?? '';
const merchant = process.env.MERCHANT_CODE ?? '';
if (!/^api_[0-9a-f]{32}$/.test(key) || !/^[0-9a-f]{64}$/.test(secret) || !/^[0-9]{6}$/.test(merchant)) {
  throw new Error('Set API_KEY, API_SECRET and MERCHANT_CODE.');
}

export async function api(method, path, body, idempotencyKey, extraHeaders = {}) {
  const url = new URL(path, base);
  if (url.origin !== base || !url.pathname.startsWith('/api/')) throw new Error('Invalid API URL');
  method = method.toUpperCase();
  const raw = body === undefined ? '' : JSON.stringify(body);   // hash and send the same bytes
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const canonical = [timestamp, method, url.pathname,
    createHash('sha256').update(raw, 'utf8').digest('hex')].join('\n');
  const signature = createHmac('sha256', secret).update(canonical, 'utf8').digest('hex');
  const response = await fetch(url, {
    method,
    redirect: 'error',
    headers: {
      ...extraHeaders,
      'X-Api-Key': key, 'X-Merchant-Code': merchant,
      'X-Timestamp': timestamp, 'X-Signature': signature,
      'Accept': 'application/json', 'Accept-Language': 'en',
      ...(raw ? {'Content-Type': 'application/json'} : {}),
      ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {}),
    },
    ...(raw ? {body: raw} : {}),
  });
  const text = await response.text();
  const data = text ? JSON.parse(text) : null;
  if (!response.ok) {
    throw Object.assign(new Error(`${response.status} ${data?.code ?? ''} ${data?.detail ?? ''}`.trim()),
      {status: response.status, problem: data, correlationId: response.headers.get('x-correlation-id')});
  }
  return data;
}
export {randomUUID};
<?php
// payniko.php - signed request helper (PHP 8.1+ with the curl extension).
function payniko_api(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null, array $extraHeaders = []): ?array
{
    $base = 'https://payniko.com';
    $key = getenv('API_KEY') ?: '';
    $secret = getenv('API_SECRET') ?: '';
    $merchant = getenv('MERCHANT_CODE') ?: '';
    if (!preg_match('/^api_[0-9a-f]{32}$/', $key) || !preg_match('/^[0-9a-f]{64}$/', $secret)
        || !preg_match('/^[0-9]{6}$/', $merchant)) {
        throw new RuntimeException('Set API_KEY, API_SECRET and MERCHANT_CODE.');
    }
    $method = strtoupper($method);
    // Hash and send the same bytes.
    $raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    $timestamp = (string) time();
    $canonical = implode("\n", [$timestamp, $method, explode('?', $path, 2)[0], hash('sha256', $raw)]);
    $headers = array_merge($extraHeaders, [
        'X-Api-Key: ' . $key,
        'X-Merchant-Code: ' . $merchant,
        'X-Timestamp: ' . $timestamp,
        'X-Signature: ' . hash_hmac('sha256', $canonical, $secret),
        'Accept: application/json',
        'Accept-Language: en',
    ]);
    if ($raw !== '') $headers[] = 'Content-Type: application/json';
    if ($idempotencyKey !== null) $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    $ch = curl_init($base . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_TIMEOUT => 60,
    ]);
    if ($raw !== '') curl_setopt($ch, CURLOPT_POSTFIELDS, $raw);
    $response = curl_exec($ch);
    if ($response === false) throw new RuntimeException('Network error: ' . curl_error($ch));
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $data = $response === '' ? null : json_decode($response, true);
    if ($status >= 400) {
        throw new RuntimeException($status . ' ' . ($data['code'] ?? '') . ' ' . ($data['detail'] ?? ''), $status);
    }
    return $data;
}
# payniko.py - signed request helper (Python 3.9+, standard library only).
import hashlib, hmac, json, os, re, time, urllib.error, urllib.request

BASE_URL = "https://payniko.com"
KEY = os.environ.get("API_KEY", "")
SECRET = os.environ.get("API_SECRET", "")
MERCHANT = os.environ.get("MERCHANT_CODE", "")
if not (re.fullmatch(r"api_[0-9a-f]{32}", KEY) and re.fullmatch(r"[0-9a-f]{64}", SECRET)
        and re.fullmatch(r"[0-9]{6}", MERCHANT)):
    raise SystemExit("Set API_KEY, API_SECRET and MERCHANT_CODE.")


def api(method, path, body=None, idempotency_key=None, extra_headers=None):
    method = method.upper()
    # Hash and send the same bytes.
    raw = b"" if body is None else json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
    timestamp = str(int(time.time()))
    canonical = "\n".join([timestamp, method, path.split("?", 1)[0], hashlib.sha256(raw).hexdigest()])
    signature = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
    headers = dict(extra_headers or {})
    headers.update({
        "X-Api-Key": KEY, "X-Merchant-Code": MERCHANT,
        "X-Timestamp": timestamp, "X-Signature": signature,
        "Accept": "application/json", "Accept-Language": "en",
    })
    if raw:
        headers["Content-Type"] = "application/json"
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    request = urllib.request.Request(BASE_URL + path, data=raw or None, method=method, headers=headers)
    try:
        with urllib.request.urlopen(request, timeout=60) as response:
            text = response.read()
            return json.loads(text) if text else None
    except urllib.error.HTTPError as error:
        problem = json.loads(error.read() or b"{}")
        raise RuntimeError(f"{error.code} {problem.get('code')} {problem.get('detail')}") from None

Verify your setup

Retrieving the balance is a safe first call because it does not create anything.

./payniko.sh GET '/api/account/balance'
import {api} from './payniko.mjs';

const result = await api('GET', '/api/account/balance');
console.log(result);
<?php
require __DIR__ . '/payniko.php';

$result = payniko_api('GET', '/api/account/balance');
print_r($result);
from payniko import api

result = api("GET", "/api/account/balance")
print(result)

Repeated invalid signatures temporarily lock the key: 20 failures from the same address within 15 minutes lock it for 15 minutes, and 300 failures within an hour lock it for an hour (429 1000005 with Retry-After). Correct the signing logic before you retry.

Idempotency-Key

Every POST requires an Idempotency-Key. A request resent with the same key is executed only once and returns the original result, which makes retries after a timeout safe.

  • Generate a new random key, such as a UUID, for every new operation, and store it with your order before you send the request.
  • After a timeout or a network error, resend the same body with the same key.
  • Never reuse a key with a different body; such a request is rejected with 409 (9000010, or 0055503 on the Direct API).
  • If the original request is still in progress, the response is 409: 9000039 on the Hosted API (with integration.retryable: true), or 0055503 with metadata.retry_class: retry_same_request on the Direct API. Resend the same request after a short delay.
  • Format: 8–128 characters from A-Z a-z 0-9 _ -. A missing or invalid key returns 422 1000009.
  • A signed request (X-Timestamp and X-Signature) is bound to the first Idempotency-Key it is sent with, for 10 minutes; the same signature with a different key is rejected with 409 9000010. Generate a new timestamp and signature for every new operation. If this check is temporarily unavailable, the response is 503 1000011.

Request requirements

  • Send only documented fields. Unknown fields are rejected with 422 1000001.
  • POST requests must not include a query string. GET requests must not include a body and accept only the documented query parameters.
  • The body must be a single JSON object nested no more than 7 levels deep, including the top-level object.
  • Rate limits apply per IP address (600 requests per minute before authentication) and per key (300 requests per minute). On 429 (1000005), wait for the Retry-After interval, then retry with backoff. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining.
HeaderMeaning
X-Correlation-IdUnique ID of the request. Log it and include it in support requests.
Retry-AfterNumber of seconds to wait. Always present on 429; present on 503 when known.
X-RateLimit-LimitRequest limit of the current window. There is no X-RateLimit-Reset header.
X-RateLimit-RemainingRequests remaining in the current window.
Content-LanguageLanguage of the response messages.

Authentication errors

CodeHTTPMeaningRecommended action
0055502401 / 403Access denied. 401: X-Api-Key or X-Merchant-Code is missing, a header is duplicated or malformed, the key is unknown or revoked, the merchant code does not match, or X-Environment was sent and does not match the key. 403: the account or Direct API access is not active.Verify the headers and the API key. Do not retry automatically.
0055504401X-Timestamp or X-Signature is missing or malformed, or your server clock differs from Payniko server time by more than 300 seconds.Correct your server clock (NTP) and sign the request again with a current timestamp.
0055505401The signature does not match the request.Hash the exact body bytes you send, sign the path without the query string, and use the correct API secret.
9000002401The request had expired by the time it was processed.Sign the request again with a current timestamp and resend it.
1000009422Idempotency-Key is missing or invalid.Send a key of 8–128 characters from A-Z a-z 0-9 _ - with every POST.
9000010409This Idempotency-Key has already been used for a different request, or the same signed request was resent with a different key.Retry with the same key and the same body; use a new key only for a new operation.
1000005429Too many requests, or a temporary lockout after repeated signature failures.Wait for the Retry-After interval, then retry with backoff.

Hosted API

Your server creates the payment and redirects the payer to the Payniko payment page. Payniko collects the card details and performs 3-D Secure, so card data never reaches your systems. The Hosted API is the simplest integration option.

Integration steps

  1. Create the payment

    Your server calls POST /api/payments/hosted. Store the returned payment_id with your order.

  2. Redirect the payer

    Redirect the browser to next_action.url or open it in a new tab; never load it inside an iframe.

  3. The payer completes the payment

    The payer enters the card details. If the bank requires 3-D Secure, the page presents it. An interim decline at this stage is not final; the payment reaches a final status without any action on your side.

  4. The payer returns

    The page displays the result with a short countdown, then redirects the browser with signed parameters to success_url (only if the payment succeeded) or to fail_url. See Return URL.

  5. Confirm the result

    Wait for the webhook or call GET /api/payments/{payment_id}. Fulfil the order only when the status is succeeded.

Payment flow

Your server                          Payniko                        Payer's browser
 1  POST /api/payments/hosted  ─────►  201  status: requires_action
                               ◄─────  next_action.url
 2  redirect (303) ───────────────────────────────────────────────►  payment page
 3                                     card + 3-D Secure  ◄────────  payer pays
 4                                     ───────────────────────────►  success_url / fail_url
                                                                     (signed parameters)
 5  webhook  ◄───────────────────────  payment.succeeded / payment.failed
    GET /api/payments/{payment_id} ──►  current status
POST/api/payments/hostedIdempotency-Key required

Create a payment

Returns 201 with the new payment. Resending the same request with the same Idempotency-Key returns 200 with the same payment.

Request body

FieldTypeRequiredDescriptionExample
merchant_order_idstringRequiredYour order reference: 1–64 characters from A-Z a-z 0-9 . _ -. Each reference can be used for one payment per environment, even if that payment failed or expired. Uniqueness is case-insensitive: ORDER-1 and order-1 are treated as the same reference.ORDER-2026-001
amountstringRequiredPositive amount with two decimal places and no leading zeros, for example "100.00". Must be within your account limits."100.00"
currencystringOptionalIf omitted, the system main currency applies.
USDEURAEDTRY
USD
webhook_urlstring · uriRequiredEndpoint that receives the webhooks for this payment. Public HTTPS on port 443, at most 500 characters, without user info or fragment. Private and local addresses are not accepted, including in sandbox, and the host name must resolve to a public IP address at the time of the request; otherwise 422 9000008.https://merchant.example/webhooks/payments
success_urlstring · uriRequiredRedirect target for the browser after a successful payment. HTTPS, at most 2048 characters, without user info or fragment. Sandbox also accepts http://localhost, 127.0.0.1 and [::1].https://merchant.example/payment/success
fail_urlstring · uriRequiredRedirect target for the browser when the payment fails, expires or is cancelled. The same rules as for success_url apply.https://merchant.example/payment/failure
customerobjectRequiredThe payer's contact details, used for the payment result, the receipt and the bank's verification steps. Only email and phone are accepted; any other field inside customer is rejected with 422 1000001.{ … }
customer.emailstringRequiredThe payer's real e-mail address: RFC 5322, at most 254 characters, with a dotted domain. It is stored in lower case.[email protected]
customer.phonestringRequiredThe payer's real phone number in international format (+ country code, E.164), for example +37061234567. Türkiye-specific rule: +90 followed by the 10-digit national number (5xx mobile, 2xx, 3xx, 4xx landline, 8xx special). A number sent without a country code is treated as a Turkish number (+90).+37061234567

These three URLs apply to this payment only and are not taken from your panel settings.

The payment page does not ask the payer for the e-mail address or the phone number again. For privacy, customer is not returned in the payment object or in webhooks.

FieldReasonMessage in errors
customer.emailInvalid format or longer than 254 characters.customer.email must be a valid e-mail address (max 254 characters).
customer.emailPlaceholder, example or disposable address.customer.email must be the payer's real address; placeholder, example or disposable addresses (example.com, test@…, mailinator…) are not accepted.
customer.phoneNot a valid number in international format.customer.phone must be a valid phone number in international format (e.g. +37061234567).
customer.phoneRepeated or sequential digit pattern.customer.phone must be the payer's real number; repeated or sequential digit patterns (5555555555, 1234567890) are not accepted.
customer.phoneTurkish number not in the form +90 followed by 10 digits starting with 2, 3, 4, 5 or 8.customer.phone for Türkiye must be +90 followed by a 10-digit number starting with 2, 3, 4, 5 or 8 (e.g. +905321234567).

HTTP 422 · 1000001

{
  "type": "https://payniko.com/problems/1000001",
  "title": "Validation failed",
  "status": 422,
  "detail": "The submitted fields are not valid.",
  "code": "1000001",
  "correlation_id": "4c7a1e9d-2b3f-4d85-9a6e-0f1b2c3d4e5f",
  "errors": {
    "customer.email": [
      "customer.email must be the payer's real address; placeholder, example or disposable addresses (example.com, test@…, mailinator…) are not accepted."
    ],
    "customer.phone": [
      "customer.phone must be the payer's real number; repeated or sequential digit patterns (5555555555, 1234567890) are not accepted."
    ]
  }
}

If a request is rejected because of customer, show the payer the reason clearly (for example: enter your own e-mail address or phone number) and let them correct the field before you create the payment again. The messages in errors are in English and intended for your logs and support.

Payniko payment link pages apply the same rules to the e-mail address and phone number the payer enters there, and show the payer the reason for a rejection.

Request body · Example

{
  "merchant_order_id": "ORDER-2026-001",
  "amount": "100.00",
  "currency": "USD",
  "webhook_url": "https://merchant.example/webhooks/payments",
  "success_url": "https://merchant.example/payment/success",
  "fail_url": "https://merchant.example/payment/failure",
  "customer": {
    "email": "[email protected]",
    "phone": "+37061234567"
  }
}
KEY="$(openssl rand -hex 16)"  # store with the order before sending; reuse it after network errors
./payniko.sh POST '/api/payments/hosted' '{
  "merchant_order_id": "ORDER-2026-001",
  "amount": "100.00",
  "currency": "USD",
  "webhook_url": "https://merchant.example/webhooks/payments",
  "success_url": "https://merchant.example/payment/success",
  "fail_url": "https://merchant.example/payment/failure",
  "customer": {
    "email": "[email protected]",
    "phone": "+37061234567"
  }
}' "$KEY"
import {api, randomUUID} from './payniko.mjs';

const key = randomUUID(); // Store this key with the order before sending; reuse it after network errors.
const payment = await api('POST', '/api/payments/hosted', {
  "merchant_order_id": "ORDER-2026-001",
  "amount": "100.00",
  "currency": "USD",
  "webhook_url": "https://merchant.example/webhooks/payments",
  "success_url": "https://merchant.example/payment/success",
  "fail_url": "https://merchant.example/payment/failure",
  "customer": {
    "email": "[email protected]",
    "phone": "+37061234567"
  }
}, key);
console.log(payment);
<?php
require __DIR__ . '/payniko.php';

$key = bin2hex(random_bytes(16)); // store with the order; reuse it after network errors
$payment = payniko_api('POST', '/api/payments/hosted', [
    "merchant_order_id" => 'ORDER-2026-001',
    "amount" => '100.00',
    "currency" => 'USD',
    "webhook_url" => 'https://merchant.example/webhooks/payments',
    "success_url" => 'https://merchant.example/payment/success',
    "fail_url" => 'https://merchant.example/payment/failure',
    "customer" => [
        "email" => '[email protected]',
        "phone" => '+37061234567',
    ],
], $key);
print_r($payment);
from payniko import api
import uuid

key = str(uuid.uuid4())  # store with the order; reuse it after network errors
payment = api("POST", "/api/payments/hosted", {
    "merchant_order_id": "ORDER-2026-001",
    "amount": "100.00",
    "currency": "USD",
    "webhook_url": "https://merchant.example/webhooks/payments",
    "success_url": "https://merchant.example/payment/success",
    "fail_url": "https://merchant.example/payment/failure",
    "customer": {
        "email": "[email protected]",
        "phone": "+37061234567"
    }
}, key)
print(payment)

Response · 201

Returns { "data": Payment } with status: requires_action and next_action.url.

Response · Example

{
  "data": {
    "payment_id": "LUVEI-PAYN-123456",
    "merchant_order_id": "ORDER-2026-001",
    "flow": "hosted",
    "status": "requires_action",
    "amount": "100.00",
    "currency": "USD",
    "environment": "sandbox",
    "next_action": {
      "type": "redirect",
      "url": "https://payniko.com/l/example-checkout",
      "expires_at": "2026-11-02T12:00:00.000Z"
    },
    "card": null,
    "failure": null,
    "created_at": "2026-11-01T12:00:00.000Z",
    "expires_at": "2026-11-02T12:00:00.000Z",
    "completed_at": null
  }
}

Other responses

  • 200: the same key and body were resent. The original payment is returned and no new payment is created.
  • 409 9000010: this key has already been used with a different body.
  • 409 9000039: the first request with this key is still being processed. Resend the same request after a short delay.
  • 409 9000007: a payment already exists for this merchant_order_id.
  • 422 9000005: the amount is outside your account limits; limits contains the permitted range.
  • 422 1000001 with customer, customer.email or customer.phone in errors: the contact details are missing or invalid.
  • 422 1000001, 1000020, 1000021, 9000008, 9000038: a field is invalid; details are in errors and integration.violations.

HTTP 422 · 9000005

{
  "type": "https://payniko.com/problems/9000005",
  "title": "Invalid amount",
  "status": 422,
  "detail": "The amount is outside the account limits.",
  "code": "9000005",
  "correlation_id": "6d1f0c2e-8a4b-4c7d-9e21-3f5a7b9c1d20",
  "limits": {
    "currency_code": "USD",
    "min_amount": "25.00",
    "max_amount": null
  }
}

Limits are evaluated in the system main currency (limits.currency_code) after your amount has been converted. max_amount: null means there is no upper limit. The limit values in this example are illustrative.

Payment object

Create and retrieve calls return this object inside data, and every payment webhook contains it. Its structure is identical for the Hosted API and the Direct API.

FieldTypeDescription
payment_idstring · LUVEI-ABCD-123456Payniko ID of the payment. Store it with your order.
merchant_order_idstringYour order reference, exactly as submitted.
flowstringhosted for the Hosted API, direct for the Direct API.
hosteddirect
statusstringCurrent status of the payment; see the status table below.
requires_actionprocessingsucceededfailedexpiredcancelled
amountstringAmount with two decimal places.
currencystringCurrency of the amount; the system main currency if none was specified.
environmentstringEnvironment of the key that created the payment.
sandboxlive
next_actionobject | nullThe next action required from the payer. Set only while status is requires_action.
next_action.typestringAlways redirect.
next_action.urlstring · uriOpen this URL in the payer's browser exactly as returned. Never construct or modify it.
next_action.expires_atstring · date-timeDeadline for using the URL. With the Direct API, this is the end of the 3-D Secure step and can be earlier than the payment's own expires_at; with the Hosted API, it is the payment deadline. Once it passes, the payment proceeds to its next status and next_action becomes null.
cardobject | nullMasked card details, once known.
card.bin6string | nullFirst six digits.
card.last_fourstring | nullLast four digits.
card.networkstring | nullCard network.
visamastercardamextroyunionpaydiscoverjcbdinersmaestro
card.bankstring | nullIssuing bank, if known.
failureobject | nullReason for the failure. Set only when status is failed.
failure.codestringReason code for use in your logic; see the list below.
failure.messagestringShort message suitable for display to the payer as plain text.
created_atstring · date-timeTime at which the payment was created.
expires_atstring · date-timeDeadline for completing the payment.
completed_atstring · date-time | nullTime at which the payment reached a final status.

next_action can briefly be null even in requires_action; in that case, retrieve the payment again. The full card number, CVV, expiry date and cardholder name are never returned. card.network and card.bank can be null when unknown.

The customer details sent when the payment was created are not part of this object and do not appear in webhooks.

Payment · Example

{
  "data": {
    "payment_id": "LUVEI-PAYN-123456",
    "merchant_order_id": "ORDER-2026-001",
    "flow": "hosted",
    "status": "requires_action",
    "amount": "100.00",
    "currency": "USD",
    "environment": "sandbox",
    "next_action": {
      "type": "redirect",
      "url": "https://payniko.com/l/example-checkout",
      "expires_at": "2026-11-02T12:00:00.000Z"
    },
    "card": null,
    "failure": null,
    "created_at": "2026-11-01T12:00:00.000Z",
    "expires_at": "2026-11-02T12:00:00.000Z",
    "completed_at": null
  }
}

Hosted payment statuses

StatusMeaningRecommended action
requires_actionThe payment has been created and the payer has not yet completed it.Redirect the payer to next_action.url (until next_action.expires_at).
processingThe payer has paid and the bank response is pending.Wait for the webhook and do not create a new payment. If the bank never responds, the payment can remain processing until it is reconciled and then ends as failed or expired.
succeededThe funds have been collected.Fulfil the order.
failedThe payment was not completed. failure contains the reason.Display failure.message. To retry, create a new payment with a new merchant_order_id.
expiredThe payer did not complete the payment within 24 hours (expires_at). If the payer abandons the payment midway, it can end as failed instead.If the payer still intends to pay, create a new payment.
cancelledThe payment was cancelled.Do not fulfil the order.
  • An interim decline on the payment page is not final: the payment can remain requires_action, and you may receive payment.updated. You ultimately receive one final result.
  • succeeded, failed, expired and cancelled are final statuses. A processing payment can still succeed after expires_at.
  • Treat any status not listed here as processing and retrieve the payment again.

Failure codes

A failed payment contains failure.code and failure.message. Use the code in your logic and display the message to the payer as plain text.

CodeMeaning
payment_declinedThe bank declined the payment.
do_not_honorThe bank declined the payment without stating a reason.
insufficient_fundsThe card has insufficient funds.
card_limit_reachedA card limit has been reached.
card_details_invalidThe card details are invalid.
card_expiry_invalidThe expiry date is invalid or the card has expired.
card_security_code_invalidThe CVV is invalid.
card_not_activeThe card is blocked or not active.
card_brand_not_supportedThis card network is not supported.
three_ds_failed3-D Secure authentication failed or was not completed.
payment_not_permittedThe card is not permitted for this type of payment.
amount_not_allowedThe amount is not allowed for this card.
currency_not_supportedThe currency is not supported for this card.
payment_details_rejectedThe bank rejected the payment details.
duplicate_paymentThe bank identified the payment as a duplicate.
payment_timed_outThe payment was not completed in time.
bank_unavailableThe bank could not be reached.
bank_requested_retryThe bank requested a retry at a later time.
service_unavailableThe payment could not be processed at this time.

New codes may be added; handle any unknown code as payment_declined. failure.message may contain the bank's own text; otherwise it is in English in webhooks and follows Accept-Language in GET.

Redirect requirements

  • Redirect the full page (303 or window.location) to next_action.url or open it in a new tab. Do not embed it in an iframe; the page blocks framing.
  • Use the URL before next_action.expires_at. Once it has expired, retrieve the payment.
  • The page language follows the payer's browser settings; no parameter is required.

Return URL

When the payer returns, the browser is redirected to success_url only if the payment succeeded, and to fail_url in all other cases. Signed parameters are appended to the URL. Verify them to display the correct page, and fulfil the order only after the webhook or a GET confirms succeeded.

FieldDescription
payment_idThe payment ID .
merchant_order_idYour order reference. It is not signed: use it only to locate the order, and match on payment_id.
statusPayment status at the time of the redirect, with the same values as in GET. Always succeeded on success_url. On fail_url, usually failed, expired or cancelled; with the Hosted API it can be requires_action or processing if the payer left before the payment reached a final status, for example through "Return to store".
failure_codePresent only when status is failed; identical to failure.code.
tsUnix time at which the return link was created. The payer can open the link later, so an old ts alone does not indicate forgery.
key_versionIdentifies the key that signed the link: 1 for the first key created in that environment, 2 for the next, and so on. The value never changes for a given payment.
sigLowercase hex HMAC-SHA256, 64 characters.

String to sign

ts + "." + payment_id + "." + status + "." + failure_code   // failure_code empty: keep the last dot
  1. Select the secret of the key identified by key_version. This is the key that created the payment, even if keys have since been replaced.

  2. Build the string from the URL-decoded values. If failure_code is absent, keep the trailing dot.

  3. Compute the HMAC-SHA256 and compare it with sig in constant time. Confirm that payment_id belongs to one of your payments.

  4. Retrieve the payment with GET /api/payments/{payment_id} (or wait for the webhook) before you fulfil the order.

  • Your own query parameters are preserved. If the URL already contains a parameter with one of the names above, it is replaced.
  • If the link could not be signed due to a temporary error, the URL contains none of these parameters. Treat the result as unknown and retrieve the payment.
// Node.js 20+: check the signed parameters on success_url / fail_url.
import {createHmac, timingSafeEqual} from 'node:crypto';

// query: URLSearchParams of the return URL.
// secretFor(keyVersion): the API secret of that key, or undefined if you do not know it.
export function verifyReturn(query, secretFor) {
  const sig = query.get('sig') ?? '';
  const secret = secretFor(query.get('key_version'));
  if (!secret || !/^[0-9a-f]{64}$/.test(sig)) return false;
  const signed = [query.get('ts') ?? '', query.get('payment_id') ?? '',
    query.get('status') ?? '', query.get('failure_code') ?? ''].join('.');
  const expected = createHmac('sha256', secret).update(signed, 'utf8').digest();
  return timingSafeEqual(expected, Buffer.from(sig, 'hex'));
}
// Even a valid return is not proof of payment: read GET /api/payments/{payment_id} before you deliver.
<?php
// PHP 8.1+: check the signed parameters on success_url / fail_url.
// $secretFor(string $keyVersion): the API secret of that key, or null if you do not know it.
function payniko_verify_return(array $query, callable $secretFor): bool
{
    $sig = (string) ($query['sig'] ?? '');
    $secret = $secretFor((string) ($query['key_version'] ?? ''));
    if (!$secret || !preg_match('/^[0-9a-f]{64}$/', $sig)) return false;
    $signed = implode('.', [(string) ($query['ts'] ?? ''), (string) ($query['payment_id'] ?? ''),
        (string) ($query['status'] ?? ''), (string) ($query['failure_code'] ?? '')]);
    return hash_equals(hash_hmac('sha256', $signed, $secret), $sig);
}

// $ok = payniko_verify_return($_GET, fn (string $v) => $secretsByKeyVersion[$v] ?? null);
// Even a valid return is not proof of payment: read GET /api/payments/{payment_id} before you deliver.
# Python 3.9+: check the signed parameters on success_url / fail_url.
import hashlib, hmac, re


def verify_return(query, secret_for) -> bool:
    # query: dict-like (for example Flask request.args); secret_for(key_version) -> secret or None
    sig = query.get("sig", "")
    secret = secret_for(query.get("key_version"))
    if not secret or not re.fullmatch(r"[0-9a-f]{64}", sig):
        return False
    signed = ".".join([query.get("ts", ""), query.get("payment_id", ""),
                       query.get("status", ""), query.get("failure_code", "")])
    expected = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

# Even a valid return is not proof of payment: read GET /api/payments/{payment_id} before you deliver.

Return URLs · Example

https://merchant.example/payment/success?payment_id=LUVEI-PAYN-654321&merchant_order_id=ORDER-2026-001&status=succeeded&ts=1793001234&key_version=1&sig=200e8a5d4a7a03ead362a2c076408f9d8764f8f636f58bd4c17d78a50d59eb8a
string to sign: 1793001234.LUVEI-PAYN-654321.succeeded.

https://merchant.example/payment/failure?payment_id=LUVEI-PAYN-654321&merchant_order_id=ORDER-2026-001&status=failed&failure_code=insufficient_funds&ts=1793001234&key_version=1&sig=1cd0a104ec4ec4f5b2933d48d14c0eb33d949dd7f27936a067ab8f68b413b70d
string to sign: 1793001234.LUVEI-PAYN-654321.failed.insufficient_funds

secret used in this example (fake): example_secret_not_real
GET/api/payments/{payment_id}

Retrieve a payment

Returns { "data": Payment } with the current status. Retrieving a payment never modifies it, so you can call this endpoint as often as required: after the return, when a webhook appears inconsistent, or after a timeout.

Parameters

FieldTypeRequiredDescription
payment_idstring · pathRequiredThe payment_id returned when the payment was created. Any key from the same environment can retrieve it.
./payniko.sh GET '/api/payments/LUVEI-PAYN-123456'
import {api} from './payniko.mjs';

const payment = await api('GET', '/api/payments/LUVEI-PAYN-123456');
console.log(payment);
<?php
require __DIR__ . '/payniko.php';

$payment = payniko_api('GET', '/api/payments/LUVEI-PAYN-123456');
print_r($payment);
from payniko import api

payment = api("GET", "/api/payments/LUVEI-PAYN-123456")
print(payment)

Possible errors

Payment webhooks

The following events are sent to the webhook_url of the payment. Each payment event is sent at most once per payment.

EventMeaning
payment.succeededThe payment status is succeeded. Fulfil the order.
payment.failedThe payment ended unsuccessfully: failed, expired or cancelled. See data.status.
payment.updatedAction by the payer is still required (requires_action), for example after an interim decline. This is not a final result.
refund.* · payment.voided · dispute.*Subsequent events for this payment (refunds, voids, disputes). See Webhooks.

payment.succeeded

{
  "event_id": "e0743f4c-a297-4bf0-8085-ceb7d9d5e181",
  "event_type": "payment.succeeded",
  "environment": "sandbox",
  "occurred_at": "2026-11-01T12:02:00.000Z",
  "data": {
    "payment_id": "LUVEI-PAYN-123456",
    "merchant_order_id": "ORDER-2026-001",
    "flow": "hosted",
    "status": "succeeded",
    "amount": "100.00",
    "currency": "USD",
    "environment": "sandbox",
    "next_action": null,
    "card": {
      "bin6": "400000",
      "last_four": "0002",
      "network": "visa",
      "bank": null
    },
    "failure": null,
    "created_at": "2026-11-01T12:00:00.000Z",
    "expires_at": "2026-11-02T12:00:00.000Z",
    "completed_at": "2026-11-01T12:02:00.000Z"
  }
}

Verify every webhook: the HMAC-SHA256 of X-Timestamp + "." + raw body, computed with the secret of the key that created the payment, must equal X-Signature, and X-Timestamp must be within 300 seconds. Store each event_id once and respond with 2xx promptly. For code samples and retry behaviour, see Webhooks.

Errors

CodeHTTPMeaningRecommended action
1000001422The request is invalid: an unknown field, an invalid format, a query string on a POST, an unsupported query parameter, or a body that is not a JSON object. errors lists the affected fields.Correct the listed fields.
1000009422Idempotency-Key is missing or invalid.Send a key of 8–128 characters from A-Z a-z 0-9 _ - with every POST.
9000010409This Idempotency-Key has already been used for a different request, or the same signed request was resent with a different key.Retry with the same key and the same body; use a new key only for a new operation.
9000039409The first request with this Idempotency-Key is still being processed (Hosted API).Resend the same request after a short delay.
9000007409A payment already exists for this merchant_order_id (Hosted API).Retrieve the existing payment or use a new order reference.
9000005422The amount is outside your account limits (Hosted API). limits contains the permitted range.Adjust the amount.
1000020422The amount format is invalid.Send a positive string with two decimal places and no leading zeros, for example "100.00".
1000021422The currency is not supported.Use USD, EUR, AED or TRY, or omit currency.
9000008422webhook_url is invalid.Use a public HTTPS URL on port 443 with a maximum length of 500 characters.
9000038422success_url or fail_url is invalid.Use an HTTPS URL with a maximum length of 2048 characters. Sandbox also accepts http://localhost.
9000040403The Hosted API is not enabled for your account.Contact support.
9000013409The sandbox operation is not available, for example when a sandbox-only endpoint is called with a live key.Use a sandbox key or retry later.
9000004403Your account is restricted for this operation.Contact support.
3000001503The exchange rate is temporarily unavailable.Retry later.
1000011503The service is temporarily unavailable.Retry the same request. Retrieve existing resources before creating new ones.
1000013503This feature is not yet available.Retry after retry_after seconds.
1000014503Planned maintenance is in progress. until and note provide details.Retry after the Retry-After interval.

A card decline is not an HTTP error; the payment ends as failed with failure.code. For the full list, see Error codes.

Testing with sandbox cards

Create the payment with your sandbox key, open next_action.url and pay with one of the cards below, using any future expiry date and any 3-digit CVV. For details, see Sandbox and test cards.

Card numberResult
4000000000000010Approved without 3-D Secure. With the Direct API, the create response already returns succeeded.
4000000000000002 · 4355090000044242Test 3-D Secure step, followed by succeeded.
4000000000000051 · 4355090000040000Declined before 3-D Secure (do_not_honor). Hosted API: the payment can stay requires_action for about 5 minutes and send payment.updated before it ends as failed. Direct API: the create response already returns failed.
4000000000000093 · 4355090000005151Test 3-D Secure step, followed by failed with insufficient_funds.
4000000000000044No bank response and no 3-D Secure; the payment remains processing.
4355090000089999Hosted API: processing without a 3-D Secure step. Direct API: test 3-D Secure step, followed by processing.

End-to-end example

A minimal Node.js server that creates a payment, redirects the payer, and handles the return and the webhook. It uses the request helper and the verification functions from Webhooks and Return URL.

server.mjs

// server.mjs: Hosted API from start to end (Node.js 20+, no dependencies).
import http from 'node:http';
import {randomUUID} from 'node:crypto';
import {api} from './payniko.mjs';                  // request helper (Authentication)
import {verifyWebhook} from './verify-webhook.mjs'; // webhook check (Webhooks)
import {verifyReturn} from './verify-return.mjs';   // return URL check (Return URL)

const site = 'https://merchant.example';
const orders = new Map();      // use your database
const seenEvents = new Set();  // use your database
const readBody = async (req) => { const parts = []; for await (const p of req) parts.push(p); return Buffer.concat(parts); };

http.createServer(async (req, res) => {
  const url = new URL(req.url, site);
  // 1. Create the payment, 2. redirect the payer.
  if (req.method === 'POST' && url.pathname === '/checkout') {
    const form = JSON.parse((await readBody(req)).toString('utf8')); // your checkout form: the payer's e-mail and phone
    const orderId = 'ORDER-' + Date.now();
    const key = randomUUID();
    orders.set(orderId, {key, status: 'new'});      // save the key before you call the API
    const {data} = await api('POST', '/api/payments/hosted', {
      merchant_order_id: orderId, amount: '100.00', currency: 'USD',
      customer: {email: form.email, phone: form.phone},
      webhook_url: `${site}/webhooks/payniko`,
      success_url: `${site}/payment/success`, fail_url: `${site}/payment/failure`,
    }, key);
    Object.assign(orders.get(orderId), {paymentId: data.payment_id, status: data.status});
    // next_action can be null for a moment: read the payment again before you redirect.
    res.writeHead(303, {Location: data.next_action ? data.next_action.url : `/orders/${orderId}`}).end();
    return;
  }
  // Return URL: show a page, but trust only the webhook or a GET.
  if (req.method === 'GET' && (url.pathname === '/payment/success' || url.pathname === '/payment/failure')) {
    const signed = verifyReturn(url.searchParams, () => process.env.API_SECRET);
    let status = 'unknown';
    if (signed) status = (await api('GET', `/api/payments/${url.searchParams.get('payment_id')}`)).data.status;
    res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
    res.end(status === 'succeeded' ? 'Thank you, payment received.' : 'Payment not completed.');
    return;
  }
  // Webhook: verify, store event_id once, answer 2xx fast.
  if (req.method === 'POST' && url.pathname === '/webhooks/payniko') {
    const raw = await readBody(req);
    if (!verifyWebhook(raw, req.headers, process.env.API_SECRET)) { res.writeHead(401).end(); return; }
    const event = JSON.parse(raw.toString('utf8'));
    if (!seenEvents.has(event.event_id)) {          // use a unique index in your database
      seenEvents.add(event.event_id);
      const order = orders.get(event.data.merchant_order_id);
      if (order && event.event_type.startsWith('payment.')) order.status = event.data.status;
    }
    res.writeHead(204).end();
    return;
  }
  res.writeHead(404).end();
}).listen(8080);

Webhooks

Payniko sends a signed POST to your webhook_url when the state of a payment, refund, dispute or withdrawal changes; the first attempt is usually made within a minute of the event. Verify the signature, store the event and respond with 2xx before you process it.

Events

EventMeaning
payment.succeededThe payment status is succeeded. Fulfil the order.
payment.failedThe payment ended unsuccessfully: failed, expired or cancelled. See data.status.
payment.updatedAction by the payer is still required (requires_action), for example after an interim decline. This is not a final result.
refund.completedA refund has been completed.
payment.voidedA void has been completed.
refund.rejectedA refund or void was rejected.
refund.failedA refund or void failed.
refund.cancelledA refund or void was cancelled.
dispute.openedA dispute was opened.
dispute.in_defenseThe dispute is under review.
dispute.wonThe dispute was decided in your favour.
dispute.lostThe dispute was decided against you.
withdrawal.sentA withdrawal was sent.
withdrawal.rejectedA withdrawal was rejected.

Payment, refund and dispute events are sent to the webhook_url of the payment, and withdrawal events to the webhook_url of the withdrawal. Each payment event is sent at most once per payment; each refund, dispute or withdrawal event at most once per refund, dispute or withdrawal and status.

Payload

FieldTypeDescription
event_idstring · uuidUnique ID of the event; identical to the X-Event-Id header.
event_typestringEvent name, as listed in the table above.
environmentstringsandbox or live.
occurred_atstring · date-timeTime at which the event occurred.
subjectobjectPresent only on refund, dispute and withdrawal events: the type and id of the refund, dispute or withdrawal.
dataobjectFor payment events, the payment object (see Hosted API); for other events, the fields listed below.

payment.succeeded

{
  "event_id": "e0743f4c-a297-4bf0-8085-ceb7d9d5e181",
  "event_type": "payment.succeeded",
  "environment": "sandbox",
  "occurred_at": "2026-11-01T12:02:00.000Z",
  "data": {
    "payment_id": "LUVEI-PAYN-123456",
    "merchant_order_id": "ORDER-2026-001",
    "flow": "hosted",
    "status": "succeeded",
    "amount": "100.00",
    "currency": "USD",
    "environment": "sandbox",
    "next_action": null,
    "card": {
      "bin6": "400000",
      "last_four": "0002",
      "network": "visa",
      "bank": null
    },
    "failure": null,
    "created_at": "2026-11-01T12:00:00.000Z",
    "expires_at": "2026-11-02T12:00:00.000Z",
    "completed_at": "2026-11-01T12:02:00.000Z"
  }
}

Refund, dispute and withdrawal events

SubjectField
refundid, status, operation_type, amount, currency, payment_id or payment_code, merchant_order_id
disputeid, status, amount, currency, payment_id or payment_code, merchant_order_id, reason, evidence_due_at (if set)
withdrawalid, status, amount, currency, usdt_amount, asset, network, tx_hash
  • Refund and dispute events carry a single payment reference: payment_id or, on some events, payment_code. refund.completed always includes payment_id. When an event carries payment_code, match the order by merchant_order_id.
  • Before you act on an event, retrieve the refund, dispute or withdrawal with GET to confirm its current state.

refund.completed

{
  "event_id": "c4b2e7d1-8a3f-4e65-b0d9-5f1a2c3e4d70",
  "event_type": "refund.completed",
  "environment": "sandbox",
  "occurred_at": "2026-11-02T09:20:00.000Z",
  "subject": {
    "type": "refund",
    "id": "LUVEI-RFND-100001"
  },
  "data": {
    "id": "LUVEI-RFND-100001",
    "status": "completed",
    "operation_type": "refund",
    "amount": "100.00",
    "currency": "USD",
    "payment_id": "LUVEI-PAYN-123456",
    "merchant_order_id": "ORDER-2026-001"
  }
}

withdrawal.sent

{
  "event_id": "b3e8f0a2-5c71-4d96-8e14-7a2c9d0f6b35",
  "event_type": "withdrawal.sent",
  "environment": "sandbox",
  "occurred_at": "2026-11-02T10:00:00.000Z",
  "subject": {
    "type": "withdrawal",
    "id": "LUVEI-WTHD-100002"
  },
  "data": {
    "id": "LUVEI-WTHD-100002",
    "status": "sent",
    "amount": "50.00",
    "currency": "USD",
    "usdt_amount": "49.950000",
    "asset": "USDT",
    "network": "TRC20",
    "tx_hash": "9f2b6c1d4e8a7f30b5c2d9e1a6f4b7c08d3e5a2f1b9c6d4e7a0f3b8c5d2e1f6a"
  }
}

Verify the signature

HeaderValue
X-Event-IdIdentical to event_id.
X-Environmentsandbox or live. Use the secret for that environment.
X-TimestampUnix time of this delivery attempt.
X-SignatureLowercase hex HMAC-SHA256 of X-Timestamp + "." + raw body.
  1. Read the raw body bytes before you parse the JSON.

  2. Verify that X-Timestamp has 10 digits and is within 300 seconds of your server time.

  3. Compute the HMAC-SHA256 of X-Timestamp + "." + raw body with your API secret, and compare the result with X-Signature in constant time.

  4. If verification fails, reject the request with 401.

// Node.js 20+: verify a Payniko notification before parsing it.
import {createHmac, timingSafeEqual} from 'node:crypto';

export function verifyWebhook(rawBody, headers, apiSecret, now = Math.floor(Date.now() / 1000)) {
  const timestamp = headers['x-timestamp'];
  const signature = headers['x-signature'];
  if (typeof timestamp !== 'string' || !/^[0-9]{10}$/.test(timestamp) ||
      typeof signature !== 'string' || !/^[0-9a-f]{64}$/.test(signature) ||
      Math.abs(now - Number(timestamp)) > 300) return false;
  const expected = createHmac('sha256', apiSecret).update(timestamp + '.').update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}

// Example with node:http - read the raw bytes first, never re-serialise the JSON.
// import http from 'node:http';
// http.createServer((req, res) => {
//   const chunks = [];
//   req.on('data', (c) => chunks.push(c)).on('end', () => {
//     const raw = Buffer.concat(chunks);
//     const secret = req.headers['x-environment'] === 'live' ? process.env.LIVE_API_SECRET : process.env.SANDBOX_API_SECRET;
//     if (!verifyWebhook(raw, req.headers, secret)) { res.writeHead(401).end(); return; }
//     const event = JSON.parse(raw.toString('utf8'));
//     // Save event.event_id (unique) and the event, then answer quickly.
//     res.writeHead(204).end();
//   });
// }).listen(8080);
<?php
// PHP 8.1+: verify a Payniko notification before parsing it.
function payniko_verify_webhook(string $rawBody, string $timestamp, string $signature, string $apiSecret, ?int $now = null): bool
{
    $now ??= time();
    if (!preg_match('/^[0-9]{10}$/', $timestamp) || !preg_match('/^[0-9a-f]{64}$/', $signature)) return false;
    if (abs($now - (int) $timestamp) > 300) return false;
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $apiSecret);
    return hash_equals($expected, $signature);
}

$raw = file_get_contents('php://input');            // raw bytes, before json_decode
$environment = $_SERVER['HTTP_X_ENVIRONMENT'] ?? '';
$secret = $environment === 'live' ? getenv('LIVE_API_SECRET') : getenv('SANDBOX_API_SECRET');
if (!payniko_verify_webhook($raw, $_SERVER['HTTP_X_TIMESTAMP'] ?? '', $_SERVER['HTTP_X_SIGNATURE'] ?? '', (string) $secret)) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
// Save $event['event_id'] (unique) and the event, then answer quickly.
http_response_code(204);
# Python 3.9+: verify a Payniko notification before parsing it.
import hashlib, hmac, re, time


def verify_webhook(raw_body: bytes, headers, api_secret: str, now=None) -> bool:
    timestamp = headers.get("X-Timestamp", "")
    signature = headers.get("X-Signature", "")
    if not re.fullmatch(r"[0-9]{10}", timestamp) or not re.fullmatch(r"[0-9a-f]{64}", signature):
        return False
    if abs(int(now or time.time()) - int(timestamp)) > 300:
        return False
    expected = hmac.new(api_secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

# Flask example:
# @app.post("/webhooks/payments")
# def payniko_webhook():
#     raw = request.get_data()                    # raw bytes, before JSON parsing
#     env = request.headers.get("X-Environment")
#     secret = os.environ["LIVE_API_SECRET" if env == "live" else "SANDBOX_API_SECRET"]
#     if not verify_webhook(raw, request.headers, secret):
#         return "", 401
#     event = json.loads(raw)
#     # Save event["event_id"] (unique) and the event, then answer quickly.
#     return "", 204

Process each event once

  1. Store event_id in a table with a unique index. If it already exists, respond with 2xx and stop processing.

  2. Respond with 2xx within a few seconds, and perform time-consuming work (e-mails, stock updates) afterwards in a queue.

  3. Events can arrive late, more than once or out of order. A payment that ends by timeout can be reported a minute or more later. Base decisions on the status in data, or retrieve the object again with GET.

  4. Never fulfil an order solely because the browser returned to success_url.

Retries

  • Any 2xx response within approximately 10 seconds counts as a successful delivery. Redirects are not followed and count as failures.
  • Failed deliveries are retried after approximately 1 minute, 5 minutes, 15 minutes, 1 hour and 6 hours. The body and X-Event-Id remain unchanged; X-Timestamp and X-Signature are regenerated for each attempt.
  • After the final attempt, the delivery is marked failed. Some errors (a private IP target, a missing signing key) are not retried, and the delivery is marked failed immediately. Failed deliveries can be resent with the deliveries API.
  • Your webhook_url must resolve to a public IP address on every attempt.

Webhook deliveries

Every webhook sent is stored as a delivery. Use this API to identify failed deliveries and resend them.

GET/api/webhooks/deliveries

List and retrieve deliveries

./payniko.sh GET '/api/webhooks/deliveries?status=failed&page=1&per_page=20'
import {api} from './payniko.mjs';

const deliveries = await api('GET', '/api/webhooks/deliveries?status=failed&page=1&per_page=20');
console.log(deliveries);
<?php
require __DIR__ . '/payniko.php';

$deliveries = payniko_api('GET', '/api/webhooks/deliveries?status=failed&page=1&per_page=20');
print_r($deliveries);
from payniko import api

deliveries = api("GET", "/api/webhooks/deliveries?status=failed&page=1&per_page=20")
print(deliveries)

Filter by status (pending, delivered, failed) and paginate with page and per_page. The list contains the deliveries for your key's environment. GET /api/webhooks/deliveries/{id} additionally returns url and payload.

FieldTypeDescription
idstring · uuidDelivery ID; identical to the event_id of the webhook.
eventstringEvent name, for example payment.succeeded.
statusstringDelivery status.
pendingdeliveredfailed
payment_idstring · LUVEI-ABCD-123456 | nullRelated payment, if any.
subject_typestring | nullrefund, dispute or withdrawal; null for payment events.
subject_idstring | nullID of the refund, dispute or withdrawal.
attempt_countintegerNumber of attempts made so far.
last_status_codeinteger | nullHTTP status returned by your endpoint on the last attempt.
last_error_codestring | nullReason for the failure of the last attempt; see the table below.
last_error_messagestring | nullHuman-readable description of last_error_code.
last_errorstring | nullFree-text error, which may be in Turkish. Use last_error_code instead.
next_attempt_atstring · date-time | nullTime of the next automatic attempt.
delivered_atstring · date-time | nullTime at which your endpoint accepted the delivery.
created_atstring · date-timeTime at which the webhook was created.
urlstringDetail response only: the target URL.
payloadobject | nullDetail response only: the stored webhook body, re-encoded (payment references are shown as payment_id). It does not match the signed bytes; do not verify signatures against it.

Response · Example

{
  "id": "0a6f3c9e-2b7d-4e15-9c84-6d1f2a3b5e70",
  "event": "payment.succeeded",
  "status": "failed",
  "payment_id": "LUVEI-PAYN-123456",
  "subject_type": null,
  "subject_id": null,
  "attempt_count": 6,
  "last_status_code": 500,
  "last_error": "Bildirim adresi başarısız yanıt verdi.",
  "last_error_code": "webhook_response_unsuccessful",
  "last_error_message": "Webhook delivery failed.",
  "next_attempt_at": null,
  "delivered_at": null,
  "created_at": "2026-11-01T12:02:00.000Z"
}
CodeMeaning
webhook_response_unsuccessfulYour endpoint responded with a non-2xx status (see last_status_code) or a redirect.
webhook_unreachableYour endpoint could not be reached (DNS, connection or timeout).
invalid_webhook_targetThe stored URL is not a valid webhook URL.
webhook_target_private_networkThe URL resolved to a private or local IP address.
webhook_environment_invalidThe environment of the webhook could not be determined.
webhook_signing_unavailableThe webhook could not be signed.
webhook_egress_proxy_unreachableA temporary delivery issue on the Payniko side.
webhook_delivery_failedAny other delivery error.
POST/api/webhooks/deliveries/{id}/retryIdempotency-Key required

Resend a failed delivery

KEY="$(openssl rand -hex 16)"  # store with the order before sending; reuse it after network errors
./payniko.sh POST '/api/webhooks/deliveries/0a6f3c9e-2b7d-4e15-9c84-6d1f2a3b5e70/retry' '' "$KEY"
import {api, randomUUID} from './payniko.mjs';

const key = randomUUID(); // Store this key with the order before sending; reuse it after network errors.
const delivery = await api('POST', '/api/webhooks/deliveries/0a6f3c9e-2b7d-4e15-9c84-6d1f2a3b5e70/retry', undefined, key);
console.log(delivery);
<?php
require __DIR__ . '/payniko.php';

$key = bin2hex(random_bytes(16)); // store with the order; reuse it after network errors
$delivery = payniko_api('POST', '/api/webhooks/deliveries/0a6f3c9e-2b7d-4e15-9c84-6d1f2a3b5e70/retry', null, $key);
print_r($delivery);
from payniko import api
import uuid

key = str(uuid.uuid4())  # store with the order; reuse it after network errors
delivery = api("POST", "/api/webhooks/deliveries/0a6f3c9e-2b7d-4e15-9c84-6d1f2a3b5e70/retry", None, key)
print(delivery)

Response · 200

Returns the delivery with status: pending.

  • Only failed deliveries can be resent; any other delivery returns 409 9000025.
  • The same webhook is queued again with the same id, the same event_id and the same body, and a new series of attempts begins.
  • Send an empty body or {}.

Possible errors

Sandbox and test cards

Test your integration with a sandbox key before going live. Sandbox provides the same API and webhooks as live, without real funds or real bank processing.

How the sandbox works

  • There is no environment switch: requests signed with a sandbox key run in sandbox, and requests signed with a live key run live.
  • The cards below work both on the payment page and in your own payment form; real cards are rejected. Your own form also accepts any other valid card number with BIN 435509 or 400000; a test page then lets you choose success, failure or no response.
  • The 3-D Secure screens are test screens, and card numbers must have a valid check digit.
  • Webhooks are delivered as real HTTPS requests to your webhook_url and signed with your sandbox secret.

Test cards

The cards behave the same with the Hosted API and the Direct API unless the table says otherwise. Use any future expiry date and any 3-digit CVV.

Card numberResult
4000000000000010Approved without 3-D Secure. With the Direct API, the create response already returns succeeded.
4000000000000002 · 4355090000044242Test 3-D Secure step, followed by succeeded.
4000000000000051 · 4355090000040000Declined before 3-D Secure (do_not_honor). Hosted API: the payment can stay requires_action for about 5 minutes and send payment.updated before it ends as failed. Direct API: the create response already returns failed.
4000000000000093 · 4355090000005151Test 3-D Secure step, followed by failed with insufficient_funds.
4000000000000044No bank response and no 3-D Secure; the payment remains processing.
4355090000089999Hosted API: processing without a 3-D Secure step. Direct API: test 3-D Secure step, followed by processing.
  • With any test card, an amount ending in .77 (for example 100.77) keeps the payment in processing (manual review).
  • A test payment that never receives a response is eventually closed as failed with service_unavailable.
  • A payment with a declined test card (for example 4000000000000051) can remain requires_action for some time before it reaches a final status. Base decisions on the webhook or a GET, not on an interim decline.

Test BINs

In sandbox, the BIN check returns the following results:

BINResult
435509 · 400000200 with payment_mode: direct.
540061200 with payment_mode: hosted; the payer continues on the sandbox payment page.
979210409 with code 0055230.
Any other six digits409 with code 0055233.

Refunds and withdrawals

In sandbox, refunds and withdrawals complete immediately against a test balance. Select the outcome with the X-Sandbox-Scenario header, which is not part of the signature. Use a new Idempotency-Key for every scenario: the header is part of the idempotency fingerprint, and the same key with a different scenario is rejected with 409 9000010. An unknown value, or the header sent with a live key, returns 422 1000001; the evidence endpoint does not accept the header. The applied scenario is echoed in the X-Sandbox-Scenario response header.

Before the scenario is applied, sandbox enforces the same rules as live: minimum and daily withdrawal limits, exchange rates (503 3000001 when unavailable) and address validation. Any TRC20 address with a valid checksum is accepted in sandbox, including the TRON zero address, and no transfer is made; 1000030 (an address that cannot receive payouts) cannot be reproduced in sandbox.

EndpointValue
POST /api/account/refundscompleted (default) or rejected
POST /api/account/withdrawalssent (default) or rejected
KEY="$(openssl rand -hex 16)"  # store with the order before sending; reuse it after network errors
SANDBOX_SCENARIO=rejected ./payniko.sh POST '/api/account/refunds' '{
  "payment_id": "LUVEI-PAYN-123456",
  "operation_type": "refund",
  "reason": "Test refund"
}' "$KEY"
import {api, randomUUID} from './payniko.mjs';

const key = randomUUID(); // Store this key with the order before sending; reuse it after network errors.
const refund = await api('POST', '/api/account/refunds', {
  "payment_id": "LUVEI-PAYN-123456",
  "operation_type": "refund",
  "reason": "Test refund"
}, key, {'X-Sandbox-Scenario': 'rejected'});
console.log(refund);
<?php
require __DIR__ . '/payniko.php';

$key = bin2hex(random_bytes(16)); // store with the order; reuse it after network errors
$refund = payniko_api('POST', '/api/account/refunds', [
    "payment_id" => 'LUVEI-PAYN-123456',
    "operation_type" => 'refund',
    "reason" => 'Test refund',
], $key, ['X-Sandbox-Scenario: rejected']);
print_r($refund);
from payniko import api
import uuid

key = str(uuid.uuid4())  # store with the order; reuse it after network errors
refund = api("POST", "/api/account/refunds", {
    "payment_id": "LUVEI-PAYN-123456",
    "operation_type": "refund",
    "reason": "Test refund"
}, key, {"X-Sandbox-Scenario": "rejected"})
print(refund)

A sandbox tx_hash is simulated and does not refer to a real blockchain transaction.

POST/api/sandbox/disputesIdempotency-Key requiredSandbox key only

Create a test dispute

Opens a dispute on a successful sandbox payment so that you can test your dispute handling and webhooks. Available only with a sandbox key; a live key receives 409 9000013.

FieldTypeRequiredDescription
payment_idstring · LUVEI-ABCD-123456RequiredOne of your sandbox payments with status succeeded.
reasonstringRequiredDispute reason.
fraudunauthorizedproduct_not_deliveredduplicateamount_mismatchother
scenariostringOptionalInitial state. Default: open.
openwonlost
KEY="$(openssl rand -hex 16)"  # store with the order before sending; reuse it after network errors
./payniko.sh POST '/api/sandbox/disputes' '{
  "payment_id": "LUVEI-PAYN-123456",
  "reason": "product_not_delivered",
  "scenario": "open"
}' "$KEY"
import {api, randomUUID} from './payniko.mjs';

const key = randomUUID(); // Store this key with the order before sending; reuse it after network errors.
const dispute = await api('POST', '/api/sandbox/disputes', {
  "payment_id": "LUVEI-PAYN-123456",
  "reason": "product_not_delivered",
  "scenario": "open"
}, key);
console.log(dispute);
<?php
require __DIR__ . '/payniko.php';

$key = bin2hex(random_bytes(16)); // store with the order; reuse it after network errors
$dispute = payniko_api('POST', '/api/sandbox/disputes', [
    "payment_id" => 'LUVEI-PAYN-123456',
    "reason" => 'product_not_delivered',
    "scenario" => 'open',
], $key);
print_r($dispute);
from payniko import api
import uuid

key = str(uuid.uuid4())  # store with the order; reuse it after network errors
dispute = api("POST", "/api/sandbox/disputes", {
    "payment_id": "LUVEI-PAYN-123456",
    "reason": "product_not_delivered",
    "scenario": "open"
}, key)
print(dispute)

Response · 201

Returns the dispute object. An open test dispute accepts responses for 7 days; won and lost are final immediately. The under_review status does not occur in sandbox, and test disputes do not affect your balance.

Request log

The Request activity page lists your recent API requests with status, error code and correlation_id. Requests rejected before the key is identified (invalid key, signature or merchant code) are not listed. Sandbox payments are listed under Sandbox API orders.

Error codes

Errors are returned as application/problem+json. Base your logic on the HTTP status and the 7-digit code; title and detail are intended for human readers.

Error response format

FieldDescription
typeURL of the error type: https://payniko.com/problems/{code}.
titleShort summary of the error.
statusHTTP status code.
detailExplanation of the error in this request.
codeStable 7-digit code; use it in your logic.
correlation_idIdentical to the X-Correlation-Id header. Include it in support requests.
errorsField-level messages for validation errors: { "field": ["message"] }.
integrationIntegration hints: stage, reason, violations (location, path, expected, hint), docs_url, retryable and, in some cases, retry_after.
metadataMay be present on Direct API errors and on authentication and rate-limit errors, with retry_class and an informational developer_detail. Treat it as optional.
limitsPermitted amount range on 9000005. On 0055235, the equivalent field is amount_limit.
retry_afterNumber of seconds to wait; present on some 503 errors.

HTTP 422 · 1000001

{
  "type": "https://payniko.com/problems/1000001",
  "title": "Validation failed",
  "status": 422,
  "detail": "The submitted fields are not valid.",
  "code": "1000001",
  "correlation_id": "9b8c7d6e-5f40-4a3b-8c2d-1e0f9a8b7c6d",
  "errors": {
    "merchant_order_id": [
      "This field is required."
    ]
  },
  "integration": {
    "stage": "validation",
    "reason": "validation_failed",
    "violations": [
      {
        "location": "body",
        "path": "merchant_order_id",
        "expected": {
          "type": "string",
          "min_length": 1,
          "max_length": 64,
          "unique_within": "account_and_environment"
        },
        "hint": "Check this field against the documented format."
      }
    ],
    "docs_url": "/en/developers#errors",
    "retryable": false
  }
}

Retry guidance

retry_classMeaning
no_retryCorrect the request before you send it again.
retry_same_requestResend the identical request after a short delay (same key, same body).
retry_afterWait for the Retry-After interval, then resend.
read_orderRetrieve the payment with GET before you decide how to proceed.
  • retry_class is present only in responses that carry metadata (Direct API, authentication and rate-limit errors). Other errors carry integration.retryable instead.
  • After a timeout or a 5xx, never create a new payment; retrieve the payment or resend the request with the same key.
  • A card decline is not an HTTP error: the payment is returned with 200, status: failed and failure.
  • developer_detail may change; do not base integration logic on it.

All error codes

Authentication and access

CodeHTTPMeaningRecommended action
0055502401 / 403Access denied. 401: X-Api-Key or X-Merchant-Code is missing, a header is duplicated or malformed, the key is unknown or revoked, the merchant code does not match, or X-Environment was sent and does not match the key. 403: the account or Direct API access is not active.Verify the headers and the API key. Do not retry automatically.
0055504401X-Timestamp or X-Signature is missing or malformed, or your server clock differs from Payniko server time by more than 300 seconds.Correct your server clock (NTP) and sign the request again with a current timestamp.
0055505401The signature does not match the request.Hash the exact body bytes you send, sign the path without the query string, and use the correct API secret.
9000002401The request had expired by the time it was processed.Sign the request again with a current timestamp and resend it.
9000040403The Hosted API is not enabled for your account.Contact support.
1000004403New payments are temporarily paused.Retry later.
9000004403Your account is restricted for this operation.Contact support.

Idempotency and conflicts

CodeHTTPMeaningRecommended action
1000009422Idempotency-Key is missing or invalid.Send a key of 8–128 characters from A-Z a-z 0-9 _ - with every POST.
9000010409This Idempotency-Key has already been used for a different request, or the same signed request was resent with a different key.Retry with the same key and the same body; use a new key only for a new operation.
9000039409The first request with this Idempotency-Key is still being processed (Hosted API).Resend the same request after a short delay.
9000007409A payment already exists for this merchant_order_id (Hosted API).Retrieve the existing payment or use a new order reference.
0055503409Direct API conflict: a payment already exists for this merchant_order_id, the Idempotency-Key was used with a different body or for a Hosted payment, or the original request is still in progress.Retrieve the payment with GET. Resend only an identical request.

Validation

CodeHTTPMeaningRecommended action
1000001422The request is invalid: an unknown field, an invalid format, a query string on a POST, an unsupported query parameter, or a body that is not a JSON object. errors lists the affected fields.Correct the listed fields.
1000020422The amount format is invalid.Send a positive string with two decimal places and no leading zeros, for example "100.00".
1000021422The currency is not supported.Use USD, EUR, AED or TRY, or omit currency.
1000023422The amount is too large.Send a smaller amount.
9000008422webhook_url is invalid.Use a public HTTPS URL on port 443 with a maximum length of 500 characters.
9000038422success_url or fail_url is invalid.Use an HTTPS URL with a maximum length of 2048 characters. Sandbox also accepts http://localhost.

Amount, card and BIN

CodeHTTPMeaningRecommended action
9000005422The amount is outside your account limits (Hosted API). limits contains the permitted range.Adjust the amount.
0055235422The amount is not accepted (Direct API and BIN check). amount_limit contains min and max when available.Adjust the amount.
0055210422The BIN is not six digits long.Send the first six digits of the card number.
0055233409The BIN is unknown.Ask the payer for a different card. This is not a bank decline.
0055230409This card cannot be used for this payment.Ask the payer for a different card. This is not a bank decline.
0055231409This card is not accepted for this payment.Ask the payer for a different card.
0055221409A limit for this card has been reached.Use a different card or retry later.
0055234409The card details are invalid: card number, expiry date or CVV. metadata.developer_detail identifies the field: card_number_invalid, card_expiry_invalid or card_cvv_invalid.Ask the payer to re-enter the card details.
0055001409Your account is not ready for this payment. With a sandbox key, this also means that the sandbox simulation is temporarily unavailable (developer_detail: sandbox_unavailable), equivalent to 9000013.Contact support.
0055002403Card payments are not enabled for your account.Contact support.
9000013409The sandbox operation is not available, for example when a sandbox-only endpoint is called with a live key.Use a sandbox key or retry later.

Not found

CodeHTTPMeaningRecommended action
9000009404The payment does not exist in this account and environment, or it cannot be used for this operation.Verify the payment_id and use a key from the same environment.
1000002404Not found: the path is unknown, or this API product is not enabled on the platform. An API disabled for your account returns 403 instead (9000040 on the Hosted API, 0055502 on the Direct API).Verify the path; contact support if the API should be available.
1000007405The HTTP method is not allowed. Allow lists the valid methods.Use the documented method.

Limits and availability

CodeHTTPMeaningRecommended action
1000005429Too many requests, or a temporary lockout after repeated signature failures.Wait for the Retry-After interval, then retry with backoff.
1000011503The service is temporarily unavailable.Retry the same request. Retrieve existing resources before creating new ones.
1000013503This feature is not yet available.Retry after retry_after seconds.
1000014503Planned maintenance is in progress. until and note provide details.Retry after the Retry-After interval.
3000001503The exchange rate is temporarily unavailable.Retry later.
1000006500An unexpected server error occurred.Retry the same request or retrieve the resource.

Account operations

CodeHTTPMeaningRecommended action
9000014409This payment has an open or completed refund or void, or a dispute that was not won.Retrieve the refund or the dispute.
9000015404The dispute does not exist.Verify the dispute ID.
9000016409The dispute no longer accepts a response (it is closed or past evidence_due_at).Retrieve the dispute.
9000017422The TRC20 address is invalid.Verify the address with its owner.
1000030422This address cannot receive payouts.Use a different address.
9000018422address_acknowledged is not true.Confirm the address, then send true.
9000019422The withdrawal amount is invalid or below the minimum.Adjust the amount.
9000020409Insufficient available balance.Check the available balance.
9000021409The daily withdrawal limit has been reached.Retry the next day or request a smaller amount.
9000022409Another withdrawal is currently being processed.Wait, then resend the same request.
9000023404The withdrawal does not exist.Verify the withdrawal ID.
9000024404The webhook delivery does not exist.Verify the delivery ID.
9000025409Only failed deliveries can be resent.Check the delivery status.

Sandbox only

CodeHTTPMeaningRecommended action
9000026503The sandbox is not yet available for this operation.Retry after the Retry-After interval.
9000027409The test balance is in use by another operation.Resend the same request after a short delay.
9000030409The test balance could not be credited.Retry later.
9000033409The test balance is insufficient for this refund. A sandbox withdrawal with insufficient balance returns 9000020 instead.Create additional test payments first.

Message language

title and detail follow Accept-Language (en, tr, de, ar, es, el; de-AT is treated as de). Any other value returns English. Content-Language indicates the language used.

Go-live checklist

Complete this checklist before you accept real payments.

  • In sandbox, one payment has ended as succeeded and another as failed, and your order states were updated accordingly.

  • Your webhook endpoint verifies the signature against the raw body, rejects stale timestamps, stores each event_id once and responds with 2xx promptly.

  • You fulfil orders only after a verified webhook or a GET shows succeeded, never on the basis of the browser return.

  • Every POST stores its Idempotency-Key before sending and reuses it after network errors.

  • Your code handles requires_action, processing, 429 and 5xx without creating duplicate payments.

  • The live key and secret are stored only in your secret store. Replacing the sandbox key with the live key is the only code change.

  • webhook_url, success_url and fail_url point to your production HTTPS addresses.

  • You log X-Correlation-Id for every request.

Even when keys are ready, verify your integration with this checklist before accepting real payments. Completing a test is not a key-generation requirement.