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
- Your server creates a payment:
POST /payments. - The response carries
checkout_url— our payment page. - You send the customer there (or show it in a frame on your own site).
- When the payment finishes, we send you a webhook
payment.succeeded. - 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”.
| Key | Looks like | What it does |
|---|---|---|
| Live | exz_live_… | Real money |
| Test | exz_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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxTest 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.
| HTTP | type | When |
|---|---|---|
| 400 | invalid_request_error | Malformed JSON |
| 401 | authentication_error | No key, or the key is invalid or revoked |
| 403 | authentication_error | API is disabled for the merchant |
| 404 | invalid_request_error | Object not found |
| 409 | invalid_request_error | Conflict: idempotency, unsuitable status |
| 422 | invalid_request_error | A field value is not acceptable |
| 429 | rate_limit_error | Too many requests |
| 5xx | api_error | Our side or the gateway |
Rate limit
By default 120 requests per minute per key. Every response carries:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118Above 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
POST /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"
}| Field | Req. | Description |
|---|---|---|
amount | yes | Integer, minor units |
currency | no | AZN by default; the list is enabled by us |
order_id | no | Your own order number. You can search by it later |
description | no | Up to 200 characters, visible to the customer |
method | no | card (default) or sbp |
return_url | no | Where to send the customer back. A public https:// URL |
customer.* | no | The fuller it is, the fewer 3-D Secure declines |
metadata | no | Up to 20 keys and 4 KB. Returned as is |
locale | no | az / ru / en — language of the payment page |
customer.id | no | Customer id in your system (up to 64 characters: Latin letters, digits, _ - . : @). Saved cards are linked to it |
expires_in_minutes | no | Custom payment deadline: 5–1440 minutes. If omitted, your shop setting applies (24 hours by default) |
save_payment_method | no | true — save the card for future charges, "optional" — ask the customer with a checkbox. See “Saved cards” |
payment_method | no | pm_… — charge a saved card without the payment page |
allow_duplicate_order | no | true — allow a second payment for an already paid order_id |
appearance | no | Card 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.
| Field | Meaning |
|---|---|
livemode | true — live payment, false — sandbox |
fee | Our fee for this payment |
net | What reaches you after the fee |
charged | If charged in another currency — {"amount":…, "currency":"…"}, otherwise null |
amount_refunded | How much has already been refunded |
source | Where the payment came from: api, link, form, insales |
card | After payment: {"brand":"visa","last4":"4242","country":"AZ"} |
paid_at | Moment of payment; null before that |
customer.id | Your customer id, if you sent one |
expires_at | Until when the payment can be paid |
initiator | customer — the customer pays, merchant — a saved card is charged without the customer |
save_payment_method | What was requested: true, "optional" or false |
payment_method | Saved card (pm_…): saved after this payment or used for the charge. Otherwise null |
next_action | For requires_action: {"type":"redirect","url":"…"} — where to send the customer |
duplicate_of | Id of the first payment for the same order_id if the order was paid twice |
canceled_at | When you canceled the payment |
Statuses
| Status | Meaning |
|---|---|
pending | Created, the customer has not paid yet |
succeeded | Paid |
failed | Declined, see failure |
expired | The payment window has expired |
refunded | Refunded in full |
partially_refunded | Partially refunded |
review | Under review on our side |
canceled | Canceled by you (POST /payments/{id}/cancel) |
requires_action | A 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" }| code | What to tell the customer |
|---|---|
authentication_failed | The bank did not confirm 3-D Secure — try again |
insufficient_funds | Insufficient funds |
card_expired | The card has expired |
limit_exceeded | The card limit has been exceeded |
card_declined | The bank declined the transaction |
expired | The payment window expired, create a new payment |
Check the status
GET /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
GET /api/v2/paymentsGET /api/v2/payments?limit=25&status=succeeded&order_id=1024| Parameter | Description |
|---|---|
limit | 1–100, 25 by default |
status | Any status listed above |
order_id | Your own order number |
from, to | Dates YYYY-MM-DD or YYYY-MM-DD HH:MM |
starting_after | Cursor for the next page |
customer_id | Payments 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
POST /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.
| Code | When |
|---|---|
payment_not_payable | The payment is not paid — nothing to refund |
refund_amount_invalid | The amount exceeds what is left |
refund_failed | The 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.
POST /api/v2/payments/{id}/cancelcurl -X POST -H "Authorization: Bearer $KEY" -H "Idempotency-Key: cancel-1024" \
https://exezine.az/api/v2/payments/EXZ-20260922-7FD54BA1D8/cancel- Success —
200and the payment in thecanceledstatus, plus thepayment.canceledwebhook. - Only a payment in
pendingorrequires_actioncan be canceled. Otherwise —409 payment_not_cancelablewith its current status inerror.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_urlof 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
POST /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"
}| Value | How it works |
|---|---|
true | The 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
POST /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:
| status | What to do |
|---|---|
succeeded | The money is charged. The payment.succeeded webhook follows |
failed | The bank declined, the reason is in failure. payment.failed follows |
requires_action | The bank requires the customer's confirmation (3-D Secure). Send the customer the next_action.url link; the outcome comes by webhook |
pending | The bank has not answered yet. The outcome comes by webhook; do not retry the charge with a new idempotency key |
| Field | Description |
|---|---|
payment_method | Required for a charge: pm_… |
initiator | merchant (default) — a charge without the customer (subscription, autopay);
customer — the customer is with you right now and pressed “Pay” with the saved card |
mit_type | Only as agreed with us: the reason for a charge without the customer —
delayed_charge, increment, resubmission, reauthorization, no_show |
customer.id | If 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
| Request | What it does |
|---|---|
GET /api/v2/payment_methods?customer_id=user-77 | The 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.
POST /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
| Code | When |
|---|---|
saved_cards_disabled | The feature is not enabled for your shop |
payment_method_invalid | Card not found (a typo, a different key mode) |
payment_method_revoked | The card was removed |
payment_method_expired | The card has expired — ask the customer to pay with a new card and save it |
payment_method_unusable | The card cannot be used — ask the customer to pay again and save the card |
customer_mismatch | The card belongs to a different customer |
parameters_conflict | Both payment_method and save_payment_method in one request |
save_requires_card | Only a card can be saved, not SBP |
Payment links
For when there is no website, or an invoice is issued by hand.
POST /api/v2/links{ "amount": 12000, "currency": "AZN", "description": "Invoice #77", "max_uses": 3, "expires_at": "2026-10-01" }| Field | Description |
|---|---|
amount | Integer, minor units. Omit it and the payer enters the sum |
currency | The merchant currency by default |
description | Up to 200 characters |
max_uses | How many payments the link allows. Omit for no limit |
expires_at | After this moment the link stops working |
type | dynamic (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”.
| Event | When |
|---|---|
payment.succeeded | The payment went through |
payment.failed | Declined |
payment.expired | The payment window expired |
refund.succeeded | A refund went through |
payment.canceled | You canceled the payment |
payment_method.saved | A customer card was saved (data.object is the card, data.payment_id is the payment) |
POST 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.
X-Exezine-Event: payment.succeeded
X-Exezine-Event-Id: evt_5f0f0c55264b546ecb1a3045
X-Exezine-Attempt: 1
X-Exezine-Signature: t=1790095200,v1=6c1f…a93Separate 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
$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';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
- Answer
200quickly — 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 answer200up front: if processing fails afterwards, there will be no retry. - 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. - 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.
- Order is not guaranteed. Trust
statusin the body, not the order of arrival. - 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=succeededThese 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.
- Tell us your site’s domains — we add them to an allowlist, otherwise the browser will refuse the embedding.
- On the page:
HTML + 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 option | Description |
|---|---|
url | Required. checkout_url from the API response |
autoOpen | false — show our screen with the amount and a button first (old behaviour). Default true |
formHeight | Frame height while the card form is open, px (360–1400). Default 640 |
height | Fixed frame height, px. Disables automatic height |
onSuccess, onFailure | Payment result (for the interface) |
onReady, onResize, onMessage | Frame 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.
POST /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" }
}
}| Section | What it styles |
|---|---|
widget | The whole form: backgroundColor — background, buttonsColor — button colour, backgroundType — background pattern (0 — none), font |
header | Header (shop name, “secure payment”). "display": "none" — hide |
price, description | Amount and payment description |
button | Buttons: card — “Pay by card”, brands — Apple Pay / Google Pay, pay — “Pay” |
inputs | Input fields; inputs.cvc.type: "password" — mask the CVC |
cardFace | Card image |
footer | Footer 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
- Get a test key, call
GET /api/v2/ping— confirm connectivity. - Configure the webhook URL, press “Send a test event”, make the signature check pass.
- Run a test payment from creation to
payment.succeeded. - Repeat a request with the same
Idempotency-Key. - Test refunds — full and partial.
- Get a live key and repeat steps 3–5 with a small amount.
- Remove the test key from the live server configuration.
- If you use saved cards, test the first payment with
save_payment_method, a charge bypm_…, 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
messageas text — branch oncode.
| Symptom | Cause |
|---|---|
| Signature never matches | The framework parsed the body before the check. Use the raw one |
| Amounts are 100× too large | You sent 49.90 instead of 4990 |
422 amount_invalid | A decimal number was sent. The API takes integers only |
| Two payments for one order | No Idempotency-Key |
404 on a payment that exists | A test key in the live environment, or the reverse |
409 order_already_paid | An order with this order_id has already been paid. This is double-payment protection |
| Saved-card charges keep failing | The 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