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
- 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 |
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.
| 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 |
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 |
Final ones: succeeded, failed, expired,
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 |
{
"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.
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 |
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…a93Verifying 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. Do the heavy work in a background queue. Anything other than 2xx counts as a failure. - An event may arrive twice. Remember the event
id(evt_…) and ignore repeats. - 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.
- 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"></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
- 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.
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 |
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