API documentation

Everything a developer needs to accept payments through E-Xezine: creating payments, webhooks, refunds and cancellation, saved cards and automatic charges, the embedded form.

Revision of 03.10.2026, API version 2026-09-22

Documentation for API version 2026-09-22. Base URL — https://exezine.az/api/v2, machine-readable description — openapi.json. Interactive reference for every endpoint: open.

New on 03.10.2026: payment form styling via the appearance field, the embedded form opens immediately and sizes its frame itself (script ?v=2), the “Save card” checkbox only when you ask for it. A canceled or expired payment can no longer be paid on a bank page that is already open (details); the webhook handling rules are clarified.

New on 01.10.2026: saved cards and automatic charges, payment cancellation, a custom payment deadline and a prompt payment.expired, double-payment protection by order_id, a separate URL for test webhooks. All changes are additive: an existing integration keeps working as is.

How it works

  1. Your server creates a payment: POST /payments.
  2. The response carries checkout_url — our payment page.
  3. You send the customer there (or show it in a frame on your own site).
  4. When the payment finishes, we send you a webhook payment.succeeded.
  5. We return the customer to your return_url.

You integrate with E-Xezine only. Which acquiring bank processes the payment is our internal matter, and we can change it at any moment: the URLs, the response shape and the webhooks stay the same, so your code never notices.

Keys and authentication

Keys are issued in your merchant account, section “API and webhooks”.

KeyLooks likeWhat it does
Liveexz_live_…Real money
Testexz_test_…Sandbox, no money

A key is shown once, at creation. Lost it — issue a new one and revoke the old. Pass it with every request:

Authorization: Bearer exz_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Test and live are fully isolated: a test key will not find a live payment (you get 404), and the other way round.

A key is access to money. It lives on your server only. Never put it into website code, a mobile app or a repository.

General rules

Amounts are integers in the currency’s minor units. 10 manat = 1000, 25.50 = 2550. There are no decimal numbers in the API at all — so a rounding error of one cent simply cannot happen.

Timestamps are RFC 3339 strings: 2026-09-22T18:30:00+04:00. Encoding is UTF-8, the request body is JSON with Content-Type: application/json. Every response carries a request_id — quote it when you write to support.

Errors

Always the same envelope:

{
  "error": {
    "type": "invalid_request_error",
    "code": "amount_invalid",
    "message": "The amount must be an integer greater than zero",
    "param": "amount"
  },
  "request_id": "req_4f2c9a1b8e0d3a71"
}

Branch on code — it is stable — and not on message: the wording may change and depends on the Accept-Language: ru | az | en header.

HTTPtypeWhen
400invalid_request_errorMalformed JSON
401authentication_errorNo key, or the key is invalid or revoked
403authentication_errorAPI is disabled for the merchant
404invalid_request_errorObject not found
409invalid_request_errorConflict: idempotency, unsuitable status
422invalid_request_errorA field value is not acceptable
429rate_limit_errorToo many requests
5xxapi_errorOur side or the gateway

Rate limit

By default 120 requests per minute per key. Every response carries:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118

Above the limit you get 429 and Retry-After in seconds. Wait that long and retry; do not hammer in a loop.

Idempotency

When creating a payment, a refund or a link, send the header:

Idempotency-Key: any-unique-string-of-yours
  • Same key, same body → we return the first result together with Idempotent-Replayed: true. No double charge.
  • Same key, different body → 409 idempotency_conflict.
  • Keys live for 24 hours.

This makes retrying after a network drop safe: you will never end up with two payments instead of one.

Create a payment

CreatePOST /api/v2/payments
{
  "amount": 4990,
  "currency": "AZN",
  "order_id": "1024",
  "description": "Order #1024",
  "method": "card",
  "return_url": "https://shop.az/thanks",
  "customer": {
    "email": "buyer@example.com",
    "name": "Aysel Məmmədova",
    "phone": "+994501234567",
    "ip": "85.132.1.1",
    "country": "AZ",
    "city": "Baku",
    "address": "Nizami 10",
    "zip": "AZ1000"
  },
  "metadata": { "cart": "17", "source": "mobile" },
  "locale": "az"
}
FieldReq.Description
amountyesInteger, minor units
currencynoAZN by default; the list is enabled by us
order_idnoYour own order number. You can search by it later
descriptionnoUp to 200 characters, visible to the customer
methodnocard (default) or sbp
return_urlnoWhere to send the customer back. A public https:// URL
customer.*noThe fuller it is, the fewer 3-D Secure declines
metadatanoUp to 20 keys and 4 KB. Returned as is
localenoaz / ru / en — language of the payment page
customer.idnoCustomer id in your system (up to 64 characters: Latin letters, digits, _ - . : @). Saved cards are linked to it
expires_in_minutesnoCustom payment deadline: 5–1440 minutes. If omitted, your shop setting applies (24 hours by default)
save_payment_methodnotrue — save the card for future charges, "optional" — ask the customer with a checkbox. See “Saved cards”
payment_methodnopm_… — charge a saved card without the payment page
allow_duplicate_ordernotrue — allow a second payment for an already paid order_id
appearancenoCard form styling: colours, fonts, hiding the header and footer. See “Payment form styling”

About customer. Issuing banks score risk in 3-D Secure 2 using exactly this data. Send at least email, ip and country — it noticeably reduces declines.

About SBP. method: "sbp" works only with currency: "RUB". In any other currency you get 422 method_not_available.

Double-payment protection. If this order_id already has a paid payment, a second one is not created: you get 409 order_already_paid with that payment's id in error.payment_id. So a repeated click on “Pay” or a retried job on your side never charges twice. If a second payment is intended (for example, a top-up), send "allow_duplicate_order": true. If the customer still pays the same order twice (opened two payment pages before the first payment), the second payment gets duplicate_of filled in and we send a Telegram notification.

Payment deadline

Every payment has a deadline — the expires_at field. It is set by expires_in_minutes or, if omitted, by your shop setting (24 hours by default). After the deadline the payment page stops accepting cards, and the payment.expired webhook arrives within a few minutes. If the bank is still running 3-D Secure at the last moment, we wait up to 15 minutes past the deadline before closing the payment.

Response 201

{
  "id": "EXZ-20260922-7FD54BA1D8",
  "object": "payment",
  "livemode": true,
  "status": "pending",
  "amount": 4990,
  "currency": "AZN",
  "amount_refunded": 0,
  "fee": 125,
  "net": 4865,
  "charged": null,
  "order_id": "1024",
  "description": "Order #1024",
  "metadata": { "cart": "17" },
  "method": "card",
  "customer": { "id": "user-77", "email": "buyer@example.com", "name": "Aysel", "phone": "" },
  "card": null,
  "failure": null,
  "checkout_url": "https://exezine.az/checkout/EXZ-20260922-7FD54BA1D8",
  "return_url": "https://shop.az/thanks",
  "source": "api",
  "created_at": "2026-09-22T18:30:00+04:00",
  "next_action": null,
  "initiator": "customer",
  "save_payment_method": false,
  "payment_method": null,
  "duplicate_of": null,
  "expires_at": "2026-09-23T18:30:00+04:00",
  "paid_at": null,
  "canceled_at": null,
  "request_id": "req_c0ad2cf33ed31637"
}

id is the payment identifier on our side. Store it next to your order.

FieldMeaning
livemodetrue — live payment, false — sandbox
feeOur fee for this payment
netWhat reaches you after the fee
chargedIf charged in another currency — {"amount":…, "currency":"…"}, otherwise null
amount_refundedHow much has already been refunded
sourceWhere the payment came from: api, link, form, insales
cardAfter payment: {"brand":"visa","last4":"4242","country":"AZ"}
paid_atMoment of payment; null before that
customer.idYour customer id, if you sent one
expires_atUntil when the payment can be paid
initiatorcustomer — the customer pays, merchant — a saved card is charged without the customer
save_payment_methodWhat was requested: true, "optional" or false
payment_methodSaved card (pm_…): saved after this payment or used for the charge. Otherwise null
next_actionFor requires_action: {"type":"redirect","url":"…"} — where to send the customer
duplicate_ofId of the first payment for the same order_id if the order was paid twice
canceled_atWhen you canceled the payment

Statuses

StatusMeaning
pendingCreated, the customer has not paid yet
succeededPaid
failedDeclined, see failure
expiredThe payment window has expired
refundedRefunded in full
partially_refundedPartially refunded
reviewUnder review on our side
canceledCanceled by you (POST /payments/{id}/cancel)
requires_actionA saved-card charge is waiting for the customer to confirm it (3-D Secure), see next_action

Final ones: succeeded, failed, expired, canceled, refunded, partially_refunded.

Decline reason

"failure": { "code": "insufficient_funds", "message": "Insufficient funds on the card" }
codeWhat to tell the customer
authentication_failedThe bank did not confirm 3-D Secure — try again
insufficient_fundsInsufficient funds
card_expiredThe card has expired
limit_exceededThe card limit has been exceeded
card_declinedThe bank declined the transaction
expiredThe payment window expired, create a new payment

Check the status

Payment statusGET /api/v2/payments/{id}
curl -H "Authorization: Bearer $KEY" \
  https://exezine.az/api/v2/payments/EXZ-20260922-7FD54BA1D8

{id} is our EXZ-… or your own order_id (then the latest payment with that number is returned).

Polling the status in a loop is unnecessary and discouraged — that is what webhooks are for. Sensible polling: one check when the customer comes back to your site, and an hourly sweep over orders that are still pending.

List payments

ListGET /api/v2/payments
GET /api/v2/payments?limit=25&status=succeeded&order_id=1024
ParameterDescription
limit1–100, 25 by default
statusAny status listed above
order_idYour own order number
from, toDates YYYY-MM-DD or YYYY-MM-DD HH:MM
starting_afterCursor for the next page
customer_idPayments of one customer (your customer.id)
{
  "object": "list",
  "data": [ { "…payment…" } ],
  "has_more": true,
  "next_cursor": "EXZ-20260922-11E5866313",
  "request_id": "req_…"
}

While has_more is true, pass next_cursor as starting_after.

Refunds

RefundPOST /api/v2/payments/{id}/refunds
{ "amount": 3000, "reason": "The customer returned the goods" }

Without amount the full remaining sum is refunded.

{
  "object": "refund_result",
  "refund": {
    "id": "ref_12",
    "object": "refund",
    "payment_id": "EXZ-20260922-7FD54BA1D8",
    "amount": 3000,
    "currency": "AZN",
    "status": "succeeded",
    "reason": "The customer returned the goods",
    "created_at": "2026-09-22T19:05:00+04:00"
  },
  "payment": { "…updated payment…" },
  "request_id": "req_…"
}

Refunds of a payment: GET /api/v2/payments/{id}/refunds.

CodeWhen
payment_not_payableThe payment is not paid — nothing to refund
refund_amount_invalidThe amount exceeds what is left
refund_failedThe gateway rejected the refund

Refunds support Idempotency-Key too — always use it.

Cancel a payment

If the customer changed their mind or the order is no longer relevant, close an unpaid payment yourself instead of waiting for the deadline.

CancelPOST /api/v2/payments/{id}/cancel
curl -X POST -H "Authorization: Bearer $KEY" -H "Idempotency-Key: cancel-1024" \
  https://exezine.az/api/v2/payments/EXZ-20260922-7FD54BA1D8/cancel
  • Success — 200 and the payment in the canceled status, plus the payment.canceled webhook.
  • Only a payment in pending or requires_action can be canceled. Otherwise — 409 payment_not_cancelable with its current status in error.payment_status (for example, succeeded: the customer has already paid).
  • Before canceling we ask the bank once more: a paid payment cannot be canceled.
  • The checkout_url of a canceled payment no longer leads to payment.

If the bank page is already open. The customer may have opened the payment page before you canceled. As a rule, the payment can no longer go through there: before charging, the payment gateway asks us, and we decline a canceled payment — the customer sees “The shop has canceled this payment”. We cannot promise this 100% though (for example, if the link to the gateway fails). So handle a payment.succeeded that arrives after cancellation as a normal payment (or refund it).

Saved cards and automatic charges

For subscriptions, autopay and one-click payments: the customer enters the card once on our page, and then you charge it yourself, without the payment page. Neither you nor we see the card number — you only get a pm_… identifier and the card mask.

The feature is enabled on request: contact support and we will arrange it with the bank. Whether it is on is shown in GET /ping (features.saved_cards) and in the dashboard.

1. Save the card during payment

First paymentPOST /api/v2/payments
{
  "amount": 4990,
  "order_id": "sub-77-1",
  "save_payment_method": true,
  "customer": { "id": "user-77", "email": "buyer@example.com" },
  "return_url": "https://shop.az/thanks"
}
ValueHow it works
trueThe card is always saved. You obtain the customer's consent to future charges on your side (a checkbox when subscribing, your terms of service)
"optional"The customer sees a “Save my card for future payments” checkbox on the payment page. Not ticked — the card is not saved

The customer enters the card and passes 3-D Secure as usual. When the payment becomes succeeded, it gets a payment_method:

"payment_method": {
  "id": "pm_3f9a1c7e5b2d4f6a8c0e1b3d",
  "object": "payment_method",
  "type": "card",
  "livemode": true,
  "customer_id": "user-77",
  "card": { "brand": "visa", "last4": "4242", "exp_month": 7, "exp_year": 2030, "country": "AZ" },
  "status": "active",
  "source": "checkout",
  "metadata": {},
  "created_at": "2026-10-01T12:00:00+04:00",
  "last_used_at": null
}

The same object also arrives as a separate payment_method.saved webhook. Store pm_… next to the customer. If the customer pays with the same card again, no new card appears — the existing one is updated.

2. Charge a saved card

Automatic chargePOST /api/v2/payments
{
  "amount": 4990,
  "order_id": "sub-77-2026-11",
  "description": "Subscription, November",
  "payment_method": "pm_3f9a1c7e5b2d4f6a8c0e1b3d",
  "customer": { "id": "user-77" }
}

There is no payment page: the bank answers right away, and the 201 response already carries the outcome in status:

statusWhat to do
succeededThe money is charged. The payment.succeeded webhook follows
failedThe bank declined, the reason is in failure. payment.failed follows
requires_actionThe bank requires the customer's confirmation (3-D Secure). Send the customer the next_action.url link; the outcome comes by webhook
pendingThe bank has not answered yet. The outcome comes by webhook; do not retry the charge with a new idempotency key
FieldDescription
payment_methodRequired for a charge: pm_…
initiatormerchant (default) — a charge without the customer (subscription, autopay); customer — the customer is with you right now and pressed “Pay” with the saved card
mit_typeOnly as agreed with us: the reason for a charge without the customer — delayed_charge, increment, resubmission, reauthorization, no_show
customer.idIf sent, must match the card's customer — otherwise 422 customer_mismatch

Always send an Idempotency-Key — for example sub-77-2026-11. Retrying after a network failure returns the same payment instead of charging twice. After a bank decline (failed), however, retry with a new key and no more than once a day: banks treat frequent retries on a declined card as fraud.

3. Managing cards

RequestWhat it does
GET /api/v2/payment_methods?customer_id=user-77The customer's cards (all cards without customer_id). Parameters: limit, starting_after, include_revoked=1
GET /api/v2/payment_methods/{pm_id}One card
DELETE /api/v2/payment_methods/{pm_id}Remove the card (the customer unlinked it on your side). It can no longer be charged

Card status: active, expired (the card has expired) or revoked (removed). Test and live cards are separated just like payments.

Importing previously saved cards

If you used to work with the acquiring bank directly and already hold card tokens of customers of the same shop, you can import them so customers do not have to enter their cards again. Import is enabled on request.

ImportPOST /api/v2/payment_methods
{
  "customer_id": "user-77",
  "token": "card token previously received from the bank",
  "card": { "brand": "visa", "last4": "4242", "exp_month": 7, "exp_year": 2030 },
  "metadata": { "migrated": "2026-10" }
}

Response — 201 and the card object (pm_…). Importing the same token for the same customer again returns the same card (200). A token cannot be validated in advance: if it is invalid, the first charge is declined.

Saved card errors

CodeWhen
saved_cards_disabledThe feature is not enabled for your shop
payment_method_invalidCard not found (a typo, a different key mode)
payment_method_revokedThe card was removed
payment_method_expiredThe card has expired — ask the customer to pay with a new card and save it
payment_method_unusableThe card cannot be used — ask the customer to pay again and save the card
customer_mismatchThe card belongs to a different customer
parameters_conflictBoth payment_method and save_payment_method in one request
save_requires_cardOnly a card can be saved, not SBP

For when there is no website, or an invoice is issued by hand.

Create a linkPOST /api/v2/links
{ "amount": 12000, "currency": "AZN", "description": "Invoice #77", "max_uses": 3, "expires_at": "2026-10-01" }
FieldDescription
amountInteger, minor units. Omit it and the payer enters the sum
currencyThe merchant currency by default
descriptionUp to 200 characters
max_usesHow many payments the link allows. Omit for no limit
expires_atAfter this moment the link stops working
typedynamic (default) or static
{
  "id": "idP62kFt",
  "object": "payment_link",
  "url": "https://exezine.az/l/idP62kFt",
  "amount": 12000,
  "currency": "AZN",
  "max_uses": 3,
  "uses": 0,
  "expires_at": null,
  "active": true,
  "created_at": "2026-09-22T19:10:00+04:00"
}

Read it back: GET /api/v2/links/{id}.

Webhooks — the way to learn the outcome

The URL and the events are configured in your account: “API and webhooks”.

EventWhen
payment.succeededThe payment went through
payment.failedDeclined
payment.expiredThe payment window expired
refund.succeededA refund went through
payment.canceledYou canceled the payment
payment_method.savedA customer card was saved (data.object is the card, data.payment_id is the payment)
Event bodyPOST to your URL
{
  "id": "evt_5f0f0c55264b546ecb1a3045",
  "type": "payment.succeeded",
  "api_version": "2026-09-22",
  "livemode": true,
  "created_at": "2026-09-22T18:45:00+04:00",
  "data": { "object": { "…payment…" } }
}

For refund.succeeded, data also carries refund.

Headers
X-Exezine-Event: payment.succeeded
X-Exezine-Event-Id: evt_5f0f0c55264b546ecb1a3045
X-Exezine-Attempt: 1
X-Exezine-Signature: t=1790095200,v1=6c1f…a93

Separate URL for test events

In the dashboard you can set a second URL — “URL for test events”. Events for payments made with a test key ("livemode": false) then go there, and live events go to the main URL. The test URL has its own signing secret, so your test server never needs the live secret. If it is not set, all events go to the main URL as before; tell them apart by livemode.

Verifying the signature — mandatory

The signature is HMAC-SHA256 over the string "<t>.<raw request body>", keyed with your webhook secret from the account.

PHP
<?php
$secret = 'your_secret_from_the_account';
$body   = file_get_contents('php://input');          // RAW body, before json_decode
$sig    = $_SERVER['HTTP_X_EXEZINE_SIGNATURE'] ?? '';

if (!preg_match('~t=(\d+),v1=([a-f0-9]{64})~', $sig, $m)) {
    http_response_code(400); exit;
}
$expected = hash_hmac('sha256', $m[1] . '.' . $body, $secret);
if (!hash_equals($expected, $m[2]) || abs(time() - (int) $m[1]) > 300) {
    http_response_code(400); exit;                    // bad signature or too old
}

$event = json_decode($body, true);
// … your handling …
http_response_code(200);
echo 'OK';
Node.js
const crypto = require('crypto');

function verify(rawBody, header, secret) {
  const m = /t=(\d+),v1=([a-f0-9]{64})/.exec(header || '');
  if (!m) return false;
  const expected = crypto.createHmac('sha256', secret)
    .update(`${m[1]}.${rawBody}`).digest('hex');
  const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
  return ok && Math.abs(Date.now() / 1000 - Number(m[1])) < 300;
}

The signature covers the raw body. If your framework has already parsed the JSON, take the original string: a re-serialised JSON.stringify produces different bytes and the signature will not match. This is by far the most common cause of “my signature check fails”.

Handling rules

  1. Answer 200 quickly — but only once the event is stored. We wait 15 seconds; do the heavy work in a background queue. Anything other than 2xx counts as a failure and we retry. Do not answer 200 up front: if processing fails afterwards, there will be no retry.
  2. An event may arrive twice. Remember the event id (evt_…) and ignore repeats. Mark the event as processed in the same database transaction as the processing itself (or after it), never before: otherwise a crash halfway makes our retry look like a duplicate.
  3. Retries. Up to 8 delivery attempts in total: the first one right away, then retries after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h. After that the event is marked undelivered and can be resent from the account with a button.
  4. Order is not guaranteed. Trust status in the body, not the order of arrival.
  5. Count money by the webhook, not by the customer coming back: the customer may close the tab while the payment still goes through.

The account has a “Send a test event” button — it delivers test.ping with a real signature. Handy for checking your endpoint before going live.

Returning the customer to your site

After the payment we redirect the customer to your return_url and add:

https://shop.az/thanks?payment_id=EXZ-20260922-7FD54BA1D8&status=succeeded

These parameters are only for rendering a “thank you” page. Treat the order as paid only after the webhook or after GET /payments/{id}: the customer can edit the address bar.

Embedded form

The customer stays on your page; the card form opens in a frame served by us. Card data never passes through your site.

  1. Tell us your site’s domains — we add them to an allowlist, otherwise the browser will refuse the embedding.
  2. On the page:
IntegrationHTML + JS
<div id="pay"></div>
<script src="https://exezine.az/assets/js/exezine-checkout.js?v=2"></script>
<script>
  // checkout_url came in the response of POST /api/v2/payments
  ExezineCheckout.mount('#pay', {
    url: payment.checkout_url,
    onSuccess: function (p) { location.href = '/thanks?id=' + p.payment_id; },
    onFailure: function (p) { alert('The payment did not go through'); }
  });
</script>

A plain redirect instead of a frame:

ExezineCheckout.redirect(payment.checkout_url);

The result this script reports is for the interface. Ship goods and change the order status only on the webhook.

How the frame behaves

The card form opens immediately, with no intermediate screen. If the customer closes it with the cross, the frame shows a “Pay by card” button that opens the form again. If the form closed after a successful payment, the frame shows “Checking the payment…” until the bank confirms — paying a second time is not possible at that moment.

The script sizes the frame itself: formHeight while the card form is open (640 px by default), then the height of the result message.

mount optionDescription
urlRequired. checkout_url from the API response
autoOpenfalse — show our screen with the amount and a button first (old behaviour). Default true
formHeightFrame height while the card form is open, px (360–1400). Default 640
heightFixed frame height, px. Disables automatic height
onSuccess, onFailurePayment result (for the interface)
onReady, onResize, onMessageFrame loaded / height changed / any message of ours

Load the script with ?v=2, as in the example above: browsers keep old copies of files for a long time, and a new address guarantees the new version.

Your own frame without our script. <iframe src="{checkout_url}?embed=1">. Address parameters: auto=0 — do not open the form immediately, fh=640 — form height. Change the height on the message event: if event.origin === "https://exezine.az", data.source === "exezine" and data.type === "resize", then data.height is the required height in pixels.

Payment form styling

You can style the card form to match your site: colours, fonts, corner radius, hiding the header and footer. Styling is sent with every payment in the appearance field — nothing to configure in the dashboard, and you can change it any time in your own code. It applies both to the embedded form and to the separate payment page.

A dark form matching your sitePOST /api/v2/payments
{
  "amount": 3500,
  "order_id": "fee-77-2026-11",
  "appearance": {
    "widget":  { "backgroundColor": "#0E1114", "buttonsColor": "#C6FF00", "backgroundType": 0 },
    "header":  { "display": "none" },
    "price":   { "color": "#C6FF00", "fontSize": "20px", "fontWeight": "700" },
    "button":  { "pay": { "backgroundColor": "#C6FF00", "color": "#0E1114", "borderRadius": "12px" } },
    "inputs":  { "backgroundColor": "#1A2027", "color": "#F4F6F8", "cvc": { "type": "password" } },
    "footer":  { "display": "none" }
  }
}
SectionWhat it styles
widgetThe whole form: backgroundColor — background, buttonsColor — button colour, backgroundType — background pattern (0 — none), font
headerHeader (shop name, “secure payment”). "display": "none" — hide
price, descriptionAmount and payment description
buttonButtons: card — “Pay by card”, brands — Apple Pay / Google Pay, pay — “Pay”
inputsInput fields; inputs.cvc.type: "password" — mask the CVC
cardFaceCard image
footerFooter with logos. "display": "none" — hide

Property names follow CSS, written in camelCase: backgroundColor, fontSize, fontFamily, fontWeight, borderRadius, letterSpacing, margin. Values are strings ("#0E1114", "14px"), numbers, true/false.

Limits. Up to 4 levels deep, up to 64 KB. Strings must not contain < > { }, javascript:, expression( or @import. Base64 images do not fit here — styling is about colours and fonts. On error you get 422 appearance_invalid with the exact location in error.param, e.g. appearance.header.display.

We validate the format, not the meaning of properties: a typo in a property name causes no error — the property simply has no effect. Check the look on one payment before rolling it out. If a payment is processed by a provider that does not support styling, it goes through as usual, only the form is standard.

The “Save card” checkbox appears in the form only with save_payment_method: "optional". Other API payments do not show it.

Integration checklist

  1. Get a test key, call GET /api/v2/ping — confirm connectivity.
  2. Configure the webhook URL, press “Send a test event”, make the signature check pass.
  3. Run a test payment from creation to payment.succeeded.
  4. Repeat a request with the same Idempotency-Key.
  5. Test refunds — full and partial.
  6. Get a live key and repeat steps 3–5 with a small amount.
  7. Remove the test key from the live server configuration.
  8. If you use saved cards, test the first payment with save_payment_method, a charge by pm_…, a decline and card removal.

What not to do

  • Poll GET /payments/{id} in a loop — that is what webhooks are for.
  • Trust the query parameters when the customer returns.
  • Keep the key in the browser or in a mobile app.
  • Send a payment without Idempotency-Key — a network drop gives you a duplicate.
  • Parse the error message as text — branch on code.
SymptomCause
Signature never matchesThe framework parsed the body before the check. Use the raw one
Amounts are 100× too largeYou sent 49.90 instead of 4990
422 amount_invalidA decimal number was sent. The API takes integers only
Two payments for one orderNo Idempotency-Key
404 on a payment that existsA test key in the live environment, or the reverse
409 order_already_paidAn order with this order_id has already been paid. This is double-payment protection
Saved-card charges keep failingThe card is blocked or empty. Retry no more than once a day, and after 3–4 declines ask the customer to pay again

Support

Write to support@exezine.az. Include the request_id from the response and the time to the minute — that way we find the request in the log straight away.

Did not find an answer, or does something behave differently from the text? Write to us — we answer to the point.

support@exezine.az