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
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.
Create the payment
Call POST /api/payments/direct with the card details, or with bin6 only for a hosted BIN.
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.
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 4030055502.
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
Field
Type
Required
Description
Example
bin6
string · path
Required
First six digits of the card.
540061
amount
string · query
Required
Amount with two decimal places, for example 100.00. Checked against your account limits.
100.00
currency
string · query
Optional
USD, 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¤cy=USD'
Returns { "data": ... } with the following fields:
Field
Type
Description
bin6
string
The six digits submitted.
bank
string | null
Issuing bank, if known.
network
string | null
Card network, if known.
visamastercardamextroy
card_type
string | null
Card type, if known.
creditdebitprepaid
card_program
string | null
Card programme. Currently always null.
country
string | null
Issuing country (ISO 3166-1 alpha-2), if known.
domestic
boolean
true for a domestic card; false otherwise or if unknown.
payment_mode
string | null
direct: 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. 4220055235 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¤cy=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.
}
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
Field
Type
Required
Description
Example
merchant_order_id
string
Required
Your 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
amount
string
Required
Amount with two decimal places, for example "100.00", within your account limits.
"100.00"
currency
string
Optional
Defaults to the system main currency. Use the same currency as in the BIN check.
USDEURAEDTRY
USD
webhook_url
string · uri
Required
Public 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 4229000008.
https://merchant.example/webhooks/payments
success_url
string · uri
Required
Redirect 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_url
string · uri
Required
Redirect target for the browser in all other cases. The same rules as for success_url apply.
https://merchant.example/payment/failure
customer
object
Required
The 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 4221000001.
{ … }
customer.email
string
Required
The payer's real e-mail address: RFC 5322, at most 254 characters, with a dotted domain. It is stored in lower case.
The 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
card
object
Conditional
The card details entered by the payer (see the table below). Send either card or bin6, not both.
{ … }
bin6
string
Conditional
For 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 4090055230.
540061
card.number
string
Required
13–19 digits without spaces, with a valid check digit (15 digits for American Express).
4355090000044242
card.holder_name
string
Required
Name on the card, 2–120 characters.
JONAS KAZLAUSKAS
card.expiry_month
string
Required
Two digits, 01 to 12.
12
card.expiry_year
string
Required
Two digits, for example 30. The card must not be expired, and the year can be at most 30 years ahead.
30
card.cvv
string
Required
Exactly 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.
Field
Reason
Message in errors
customer.email
Invalid format or longer than 254 characters.
customer.email must be a valid e-mail address (max 254 characters).
customer.email
Placeholder, 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.phone
Not a valid number in international format.
customer.phone must be a valid phone number in international format (e.g. +37061234567).
customer.phone
Repeated or sequential digit pattern.
customer.phone must be the payer's real number; repeated or sequential digit patterns (5555555555, 1234567890) are not accepted.
customer.phone
Turkish 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.
Redirect the browser to next_action.url (3-D Secure, or the payment page for a hosted BIN).
processing
Wait for the webhook and do not create a new payment.
succeeded
Fulfil the order.
failed
Display 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 4090055503 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 4090055503.
Invalid card details are rejected with 4090055234. 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 4221000001; 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.
Field
Type
Description
payment_id
string · LUVEI-ABCD-123456
Payniko ID of the payment. Store it with your order.
merchant_order_id
string
Your order reference, exactly as submitted.
flow
string
hosted for the Hosted API, direct for the Direct API.
hosteddirect
status
string
Current status of the payment; see the status table below.
Currency of the amount; the system main currency if none was specified.
environment
string
Environment of the key that created the payment.
sandboxlive
next_action
object | null
The next action required from the payer. Set only while status is requires_action.
next_action.type
string
Always redirect.
next_action.url
string · uri
Open this URL in the payer's browser exactly as returned. Never construct or modify it.
next_action.expires_at
string · date-time
Deadline 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.
Reason for the failure. Set only when status is failed.
failure.code
string
Reason code for use in your logic; see the list below.
failure.message
string
Short message suitable for display to the payer as plain text.
created_at
string · date-time
Time at which the payment was created.
expires_at
string · date-time
Deadline for completing the payment.
completed_at
string · date-time | null
Time 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.
The 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.
processing
The 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.
succeeded
The funds have been collected.
Fulfil the order.
failed
The payment was not completed. failure contains the reason.
Display failure.message. To retry, create a new payment with a new merchant_order_id.
expired
The payer did not complete the payment before expires_at.
If the payer still intends to pay, create a new payment.
cancelled
The payment was cancelled.
Do not fulfil the order.
Scenario
Status
Event
Approved after 3-D Secure
requires_action → succeeded
payment.succeeded
Declined
failed
payment.failed
Bank response pending
processing
None until the final result
No bank response; the attempt timed out
processing → failed / expired
payment.failed
The payer did not start and the deadline passed (if the payer abandons the payment midway, it can end as failed)
expired
payment.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.
Code
Meaning
payment_declined
The bank declined the payment.
do_not_honor
The bank declined the payment without stating a reason.
insufficient_funds
The card has insufficient funds.
card_limit_reached
A card limit has been reached.
card_details_invalid
The card details are invalid.
card_expiry_invalid
The expiry date is invalid or the card has expired.
card_security_code_invalid
The CVV is invalid.
card_not_active
The card is blocked or not active.
card_brand_not_supported
This card network is not supported.
three_ds_failed
3-D Secure authentication failed or was not completed.
payment_not_permitted
The card is not permitted for this type of payment.
amount_not_allowed
The amount is not allowed for this card.
currency_not_supported
The currency is not supported for this card.
payment_details_rejected
The bank rejected the payment details.
duplicate_payment
The bank identified the payment as a duplicate.
payment_timed_out
The payment was not completed in time.
bank_unavailable
The bank could not be reached.
bank_requested_retry
The bank requested a retry at a later time.
service_unavailable
The 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.
Field
Description
payment_id
The payment ID .
merchant_order_id
Your order reference. It is not signed: use it only to locate the order, and match on payment_id.
status
Payment 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_code
Present only when status is failed; identical to failure.code.
ts
Unix 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_version
Identifies 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.
sig
Lowercase hex HMAC-SHA256, 64 characters.
String to sign
ts + "." + payment_id + "." + status + "." + failure_code // failure_code empty: keep the last dot
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.
Build the string from the URL-decoded values. If failure_code is absent, keep the trailing dot.
Compute the HMAC-SHA256 and compare it with sig in constant time. Confirm that payment_id belongs to one of your payments.
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
Field
Type
Required
Description
payment_id
string · path
Required
The 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'
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
Code
HTTP
Meaning
Recommended action
1000001
422
The 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.
1000009
422
Idempotency-Key is missing or invalid.
Send a key of 8–128 characters from A-Z a-z 0-9 _ - with every POST.
0055502
401 / 403
Access 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.
0055503
409
Direct 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.
0055210
422
The BIN is not six digits long.
Send the first six digits of the card number.
0055234
409
The 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.
0055233
409
The BIN is unknown.
Ask the payer for a different card. This is not a bank decline.
0055230
409
This card cannot be used for this payment.
Ask the payer for a different card. This is not a bank decline.
0055231
409
This card is not accepted for this payment.
Ask the payer for a different card.
0055221
409
A limit for this card has been reached.
Use a different card or retry later.
0055235
422
The amount is not accepted (Direct API and BIN check). amount_limit contains min and max when available.
Adjust the amount.
0055001
409
Your 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.
1000002
404
Not 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.
1000011
503
The 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 number
Result
4000000000000010
Approved without 3-D Secure. With the Direct API, the create response already returns succeeded.
4000000000000002 · 4355090000044242
Test 3-D Secure step, followed by succeeded.
4000000000000051 · 4355090000040000
Declined 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 · 4355090000005151
Test 3-D Secure step, followed by failed with insufficient_funds.
4000000000000044
No bank response and no 3-D Secure; the payment remains processing.
4355090000089999
Hosted 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¤cy=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);