API documentation

Everything a developer needs to accept payments through E-Xezine: creating payments, webhooks, refunds, the embedded form.

Revision of 22.09.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.

How it works

  1. Your server creates a payment: POST /payments.
  2. The response carries checkout_urlour 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

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.

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": { "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",
  "paid_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

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

Final ones: succeeded, failed, expired, 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
{
  "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.

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
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

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. Do the heavy work in a background queue. Anything other than 2xx counts as a failure.
  2. An event may arrive twice. Remember the event id (evt_…) and ignore repeats.
  3. Retries. On failure we retry 8 times: after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 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"></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.

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.

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

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