Skip to content
Documentation

Direct API

With the Direct API, the card form is part of your site and your server submits the card details to Payniko. The payer's browser is redirected to Payniko only for 3-D Secure and for hosted BINs. Use the Direct API when the card form must be on your own page.

Integration steps

  1. Check the BIN

    Once the payer has entered six digits, call GET /api/payments/bin/{bin6}. payment_mode indicates whether to collect the full card details in your form or to send the payer to the payment page.

  2. Create the payment

    Call POST /api/payments/direct with the card details, or with bin6 only for a hosted BIN.

  3. Complete 3-D Secure

    If the status is requires_action, redirect the browser to next_action.url. The browser then returns to your success_url or fail_url with signed parameters.

  4. Confirm the result

    Wait for the payment.succeeded or payment.failed webhook, or call GET /api/payments/{payment_id}. Fulfil the order only on succeeded.

Payment flow

Your server                          Payniko                        Payer's browser
 1  GET /api/payments/bin/{bin6} ───►  payment_mode: direct | hosted
 2  POST /api/payments/direct  ─────►  200  requires_action + next_action.url
                                            (or processing / succeeded / failed)
 3  redirect to next_action.url ──────────────────────────────────►  3-D Secure page
                                       ───────────────────────────►  success_url / fail_url
 4  webhook  ◄───────────────────────  payment.succeeded / payment.failed
    GET /api/payments/{payment_id} ──►  current status

Prerequisites

  • The Direct API is enabled per account. Without this access, requests are rejected with 403 0055502.
  • Keys, headers and signing are identical to the Hosted API; see Authentication. The examples use the request helpers.
GET/api/payments/bin/{bin6}

1. Check the BIN

Before you create the payment, the BIN check indicates whether the card can be paid in your form (direct) or must be completed on the Payniko payment page (hosted). The check does not result in any charge.

Parameters

FieldTypeRequiredDescriptionExample
bin6string · pathRequiredFirst six digits of the card.540061
amountstring · queryRequiredAmount with two decimal places, for example 100.00. Checked against your account limits.100.00
currencystring · queryOptionalUSD, EUR, AED or TRY. Defaults to the system main currency. Use the same currency when you create the payment.USD

Other query parameters are rejected. The query string is not part of the signature.

./payniko.sh GET '/api/payments/bin/540061?amount=100.00&currency=USD'
import {api} from './payniko.mjs';

const lookup = await api('GET', '/api/payments/bin/540061?amount=100.00&currency=USD');
console.log(lookup);
<?php
require __DIR__ . '/payniko.php';

$lookup = payniko_api('GET', '/api/payments/bin/540061?amount=100.00&currency=USD');
print_r($lookup);
from payniko import api

lookup = api("GET", "/api/payments/bin/540061?amount=100.00&currency=USD")
print(lookup)

Response · Example

{
  "data": {
    "bin6": "540061",
    "bank": "Test Bank",
    "network": "mastercard",
    "card_type": "credit",
    "card_program": null,
    "country": "TR",
    "domestic": true,
    "payment_mode": "hosted"
  }
}

Response · 200

Returns { "data": ... } with the following fields:

FieldTypeDescription
bin6stringThe six digits submitted.
bankstring | nullIssuing bank, if known.
networkstring | nullCard network, if known.
visamastercardamextroy
card_typestring | nullCard type, if known.
creditdebitprepaid
card_programstring | nullCard programme. Currently always null.
countrystring | nullIssuing country (ISO 3166-1 alpha-2), if known.
domesticbooleantrue for a domestic card; false otherwise or if unknown.
payment_modestring | nulldirect: collect the full card details. hosted: send the payer to the payment page. Any other value, including null: do not create a payment.
directhosted

Handling the response

  • direct: collect the full card details and create the payment with card.
  • hosted: create the payment with bin6 only. The payer enters the card details on the Payniko payment page.
  • Any other payment_mode, or a 409 card error (0055230, 0055231, 0055233): do not create a payment, and ask the payer for a different card with a neutral message, as this is not a bank decline. 422 0055235 indicates an amount issue. On 429 or 5xx, repeat the same check later.

Example

import {api} from './payniko.mjs';

let mode = null;
try {
  const lookup = await api('GET', '/api/payments/bin/411111?amount=100.00&currency=USD');
  mode = lookup.data.payment_mode;
} catch (error) {
  // No answer, 429 or 5xx: temporary. Try the same lookup again later.
  if (error.status === undefined || error.status === 429 || error.status >= 500) throw error;
  // Other 4xx: error.problem.code says why, for example 0055233.
}
if (mode !== 'direct' && mode !== 'hosted') {
  // Also null: do not create a payment. Ask for another card with a neutral message.
}

Possible errors

POST/api/payments/directIdempotency-Key required

2. Create the payment

Always returns 200 with the current payment, including when the same request is resent. A card decline is not an HTTP error; the response is 200 with status: failed.

Request body

FieldTypeRequiredDescriptionExample
merchant_order_idstringRequiredYour order reference: 1–64 characters from A-Z a-z 0-9 . _ -. One payment per reference and environment; uniqueness is case-insensitive.ORDER-2026-002
amountstringRequiredAmount with two decimal places, for example "100.00", within your account limits."100.00"
currencystringOptionalDefaults to the system main currency. Use the same currency as in the BIN check.
USDEURAEDTRY
USD
webhook_urlstring · uriRequiredPublic HTTPS URL on port 443 that receives the webhooks for this payment, at most 500 characters. 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 when the payment has succeeded. 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 in all other cases. 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
cardobjectConditionalThe card details entered by the payer (see the table below). Send either card or bin6, not both.{ … }
bin6stringConditionalFor a hosted BIN only: the first six digits. The payer enters the card details on the payment page. A request for a direct BIN with only bin6 returns 409 0055230.540061
card.numberstringRequired13–19 digits without spaces, with a valid check digit (15 digits for American Express).4355090000044242
card.holder_namestringRequiredName on the card, 2–120 characters.JONAS KAZLAUSKAS
card.expiry_monthstringRequiredTwo digits, 01 to 12.12
card.expiry_yearstringRequiredTwo digits, for example 30. The card must not be expired, and the year can be at most 30 years ahead.30
card.cvvstringRequiredExactly 3 digits, or 4 digits for American Express. Never store or log it.123

All card values are strings. Unknown fields inside card are rejected.

For a hosted BIN, 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 Direct API returns the messages in errors translated; the table shows the English text.

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.

With card details · Example

{
  "merchant_order_id": "ORDER-2026-002",
  "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"
  },
  "card": {
    "number": "4355090000044242",
    "holder_name": "JONAS KAZLAUSKAS",
    "expiry_month": "12",
    "expiry_year": "30",
    "cvv": "123"
  }
}
KEY="$(openssl rand -hex 16)"  # store with the order before sending; reuse it after network errors
./payniko.sh POST '/api/payments/direct' '{
  "merchant_order_id": "ORDER-2026-002",
  "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"
  },
  "card": {
    "number": "4355090000044242",
    "holder_name": "JONAS KAZLAUSKAS",
    "expiry_month": "12",
    "expiry_year": "30",
    "cvv": "123"
  }
}' "$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/direct', {
  "merchant_order_id": "ORDER-2026-002",
  "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"
  },
  "card": {
    "number": "4355090000044242",
    "holder_name": "JONAS KAZLAUSKAS",
    "expiry_month": "12",
    "expiry_year": "30",
    "cvv": "123"
  }
}, 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/direct', [
    "merchant_order_id" => 'ORDER-2026-002',
    "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',
    ],
    "card" => [
        "number" => '4355090000044242',
        "holder_name" => 'JONAS KAZLAUSKAS',
        "expiry_month" => '12',
        "expiry_year" => '30',
        "cvv" => '123',
    ],
], $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/direct", {
    "merchant_order_id": "ORDER-2026-002",
    "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"
    },
    "card": {
        "number": "4355090000044242",
        "holder_name": "JONAS KAZLAUSKAS",
        "expiry_month": "12",
        "expiry_year": "30",
        "cvv": "123"
    }
}, key)
print(payment)

Response · Example

{
  "data": {
    "payment_id": "3c2d7a4e-5b1f-4e8a-9d06-7f2b1c9e4a35",
    "merchant_order_id": "ORDER-2026-002",
    "flow": "direct",
    "status": "requires_action",
    "amount": "100.00",
    "currency": "USD",
    "environment": "sandbox",
    "next_action": {
      "type": "redirect",
      "url": "https://payniko.com/example-authentication-step",
      "expires_at": "2026-11-01T12:30:00.000Z"
    },
    "card": {
      "bin6": "435509",
      "last_four": "4242",
      "network": "visa",
      "bank": "Test Bank"
    },
    "failure": null,
    "created_at": "2026-11-01T12:00:00.000Z",
    "expires_at": "2026-11-01T12:30:00.000Z",
    "completed_at": null
  }
}

With bin6 for a hosted BIN · Example

{
  "merchant_order_id": "ORDER-2026-003",
  "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"
  },
  "bin6": "540061"
}

Interpreting the response

StatusRecommended action
requires_actionRedirect the browser to next_action.url (3-D Secure, or the payment page for a hosted BIN).
processingWait for the webhook and do not create a new payment.
succeededFulfil the order.
failedDisplay failure.message and ask the payer for a different card.
  • The call can take several seconds while Payniko communicates with the bank. Use a timeout of at least 60 seconds, and after a timeout, resend the request with the same Idempotency-Key and body. While the first request is still running (up to 3 minutes), the retry returns 409 0055503 with metadata.retry_class: retry_same_request: wait a few seconds and resend the same request.
  • For a hosted BIN, any card sent is ignored and the payer continues on the payment page.
  • If a payment already exists for the merchant_order_id, or the same Idempotency-Key is sent with a different body, the request is rejected with 409 0055503.
  • Invalid card details are rejected with 409 0055234. 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.
  • Missing or invalid contact details are rejected with 422 1000001; errors names customer, customer.email or customer.phone.

3. 3-D Secure and the browser

When the status is requires_action, the payer's browser must open next_action.url, where the bank performs 3-D Secure or the payer continues on the payment page.

  • Redirect the full page to next_action.url or open it in a new tab; both methods are always supported.
  • The page does not request the card details again; it collects only the browser data that the bank requires.
  • Always use the most recent next_action.url before its next_action.expires_at (the end of the 3-D Secure step). Never reopen a previous URL.
  • The browser is then redirected to success_url (only on succeeded) or fail_url with signed parameters (see Return URL). Retrieve the payment afterwards.

Framing

The 3-D Secure page cannot be displayed in a frame; redirect the full page or open a new tab. Only the payment page of a hosted BIN may be framed, with live keys only and only on the exact origin of the payment's success_url. The Hosted API payment page can never be framed.

The payment page of a hosted BIN embeds the payment provider's own secure payment and 3-D Secure screen and can be displayed as an iframe within your own payment form.

If you frame the payment page of a hosted BIN, the messages it posts to your window serve only as a signal to check the payment. Accept them only from the origin of next_action.url and from your frame, ignore their content, and have your server retrieve the payment.

Browser

// Only if you show the payment page of a hosted BIN in a frame on the exact origin of success_url.
// nextActionUrl: next_action.url from your server.
const pageOrigin = new URL(nextActionUrl).origin;
window.addEventListener('message', (event) => {
  if (event.origin !== pageOrigin || event.source !== frame.contentWindow) return;
  // A wake-up call only: ignore event.data. Ask your server to read the payment.
  fetch('/checkout/ORDER-2026-002/refresh', {method: 'POST', credentials: 'same-origin'});
});

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": "3c2d7a4e-5b1f-4e8a-9d06-7f2b1c9e4a35",
    "merchant_order_id": "ORDER-2026-002",
    "flow": "direct",
    "status": "requires_action",
    "amount": "100.00",
    "currency": "USD",
    "environment": "sandbox",
    "next_action": {
      "type": "redirect",
      "url": "https://payniko.com/example-authentication-step",
      "expires_at": "2026-11-01T12:30:00.000Z"
    },
    "card": {
      "bin6": "435509",
      "last_four": "4242",
      "network": "visa",
      "bank": "Test Bank"
    },
    "failure": null,
    "created_at": "2026-11-01T12:00:00.000Z",
    "expires_at": "2026-11-01T12:30:00.000Z",
    "completed_at": null
  }
}

Direct payment statuses

StatusMeaningRecommended action
requires_actionThe browser must open next_action.url for 3-D Secure or for the payment page of a hosted BIN.Redirect the payer to next_action.url.
processingThe bank response is pending. This status can also be returned directly in the create response.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 before expires_at.If the payer still intends to pay, create a new payment.
cancelledThe payment was cancelled.Do not fulfil the order.
ScenarioStatusEvent
Approved after 3-D Securerequires_action → succeededpayment.succeeded
Declinedfailedpayment.failed
Bank response pendingprocessingNone until the final result
No bank response; the attempt timed outprocessing → failed / expiredpayment.failed
The payer did not start and the deadline passed (if the payer abandons the payment midway, it can end as failed)expiredpayment.failed

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.

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.

Direct API: if the result is not yet final when the payer returns from 3-D Secure, the browser is not redirected to fail_url; the payer stays on a neutral Payniko result page. Rely on the webhook or a GET for the outcome.

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/3c2d7a4e-5b1f-4e8a-9d06-7f2b1c9e4a35'
import {api} from './payniko.mjs';

const payment = await api('GET', '/api/payments/3c2d7a4e-5b1f-4e8a-9d06-7f2b1c9e4a35');
console.log(payment);
<?php
require __DIR__ . '/payniko.php';

$payment = payniko_api('GET', '/api/payments/3c2d7a4e-5b1f-4e8a-9d06-7f2b1c9e4a35');
print_r($payment);
from payniko import api

payment = api("GET", "/api/payments/3c2d7a4e-5b1f-4e8a-9d06-7f2b1c9e4a35")
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.
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.
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.
0055210422The BIN is not six digits long.Send the first six digits of the card number.
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.
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.
0055235422The amount is not accepted (Direct API and BIN check). amount_limit contains min and max when available.Adjust the amount.
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.
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.
1000011503The service is temporarily unavailable.Retry the same request. Retrieve existing resources before creating new ones.

Direct API errors also include metadata.retry_class (no_retry, retry_same_request, retry_after, read_order). A closed browser window or a timeout on your side does not mean that the payment failed; retrieve the payment. For the full list, see Error codes.

Testing with sandbox cards

With a sandbox key, the BIN check and the payment use fixed test data: 435509 and 400000 are direct BINs, and 540061 is a hosted BIN. Use 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.

With the Direct API, a declined test card returns failed directly in the create response; an interim requires_action occurs only on the payment page of a hosted BIN. Base decisions on the webhook or a GET.

End-to-end example

A minimal Node.js server covering the BIN check, payment creation, 3-D Secure redirect, return URL and webhook. It uses the request helper and the verification functions from Webhooks and Return URL.

server.mjs

// server.mjs: Direct 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); };

const reply = (res, data) => {
  res.writeHead(200, {'Content-Type': 'application/json'});
  res.end(JSON.stringify(data.status === 'requires_action' && data.next_action
    ? {redirect: data.next_action.url} : {status: data.status, failure: data.failure}));
};

http.createServer(async (req, res) => {
  const url = new URL(req.url, site);
  // 1. BIN check, 2. create the payment. Your own HTTPS form posts the card here.
  if (req.method === 'POST' && url.pathname === '/pay') {
    const form = JSON.parse((await readBody(req)).toString('utf8'));
    const bin6 = form.number.slice(0, 6);
    let mode = null;
    try {
      mode = (await api('GET', `/api/payments/bin/${bin6}?amount=100.00&currency=USD`)).data.payment_mode;
    } catch (error) {
      if (error.status === undefined || error.status >= 500 || error.status === 429) throw error;
    }
    if (mode !== 'direct' && mode !== 'hosted') { res.writeHead(422).end('Please use another card.'); return; }
    const orderId = 'ORDER-' + Date.now();
    const key = randomUUID();
    orders.set(orderId, {key, status: 'new'});
    const body = {
      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`,
    };
    if (mode === 'direct') {
      body.card = {number: form.number, holder_name: form.holder_name,
        expiry_month: form.expiry_month, expiry_year: form.expiry_year, cvv: form.cvv};
    } else {
      body.bin6 = bin6;                               // payer enters the card on the payment page
    }
    const {data} = await api('POST', '/api/payments/direct', body, key);
    Object.assign(orders.get(orderId), {paymentId: data.payment_id, status: data.status});
    reply(res, data);                                 // 3. browser goes to next_action.url
    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);