APW Wallet API
Partner API, version 1: registration, login, password reset, profile, limits and notifications, utility and other service payments, wallet-to-wallet transfers, currency exchange, ATM cash withdrawal, verification and operation history.
Overview
Base URL:
https://wallet.apw.ge/api/v1
Every request is a POST with a JSON body (Content-Type: application/json), including requests with an empty body.
Authentication happens at two levels:
- Partner: your server signs every request with an HMAC signature. This is how we know the request comes from you.
- Wallet customer: after login you send the received
access_tokenin theAuthorization: Bearer …header. This works exactly as in the APW Wallet app.
The secret must be stored only on the server. Signing always happens on your backend. Never put the secret in a mobile app or in browser code.
Your app calls your server, and your server calls this API.
Partner authentication (HMAC)
You will receive two values from us: a Partner ID (e.g. pk_…) and a secret.
The secret is handed over only once. If it is lost or leaked, contact us and we will issue a new one. The old one stops working at the same moment.
Headers
| Header | Value |
|---|---|
X-Partner-Id | Your Partner ID |
X-Timestamp | Current time in unix seconds. Must not differ from the server time by more than ±300 seconds. |
X-Nonce | A random string, unique for every request: 16–64 characters from A-Z a-z 0-9 _ -. For example, a UUID. |
X-Client-Ip | The IP address of the wallet customer (end user). Not the IP of your server. |
X-Signature | HMAC-SHA256 in lowercase hex (see below) |
Content-Type | application/json |
Authorization | Bearer <access_token>. Required only on endpoints marked “customer token”. |
String to sign
Six lines joined with \n (LF). No trailing newline is added at the end.
POST
/api/v1/auth/login-check
1761140000
3f1c9a0e-5b7d-4e2a-9c61-0d8e2b7f4a10
203.0.113.7
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- The HTTP method in uppercase:
POST. - The URL path, without the domain and query string. For example
/api/v1/auth/login-check. - The value of
X-Timestamp. - The value of
X-Nonce. - The value of
X-Client-Ip. - The SHA-256 hash of the request body, in lowercase hex. The body must be exactly the bytes you send. The hash of an empty body is
e3b0c442…b855.
X-Signature = hex( HMAC-SHA256( key = secret, message = string to sign ) )
Serialize the JSON first, then sign that string, and send exactly the same string.
If your HTTP library re-serializes the object when sending it (changing whitespace, field order or number formatting), the signature will not match and you will get error 906.
function apwRequest(string $path, array $data, string $clientIp, ?string $customerToken = null): array
{
$partnerId = getenv('APW_PARTNER_ID');
$secret = getenv('APW_SECRET');
$body = json_encode($data ?: new stdClass(), JSON_UNESCAPED_UNICODE);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$stringToSign = implode("\n", [
'POST', $path, $timestamp, $nonce, $clientIp, hash('sha256', $body),
]);
$headers = [
'Content-Type: application/json',
'X-Partner-Id: ' . $partnerId,
'X-Timestamp: ' . $timestamp,
'X-Nonce: ' . $nonce,
'X-Client-Ip: ' . $clientIp,
'X-Signature: ' . hash_hmac('sha256', $stringToSign, $secret),
];
if ($customerToken) {
$headers[] = 'Authorization: Bearer ' . $customerToken;
}
$ch = curl_init('https://wallet.apw.ge' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 140,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return ['status' => $status, 'body' => json_decode((string) $response, true)];
}
$result = apwRequest('/api/v1/auth/login-check', [
'wallet_number' => '12345678901',
'password' => 'Secret123',
], $_SERVER['REMOTE_ADDR']);const crypto = require('crypto');
async function apwRequest(path, data, clientIp, customerToken) {
const body = JSON.stringify(data ?? {});
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomUUID();
const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
const stringToSign = ['POST', path, timestamp, nonce, clientIp, bodyHash].join('\n');
const signature = crypto.createHmac('sha256', process.env.APW_SECRET)
.update(stringToSign, 'utf8').digest('hex');
const headers = {
'Content-Type': 'application/json',
'X-Partner-Id': process.env.APW_PARTNER_ID,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Client-Ip': clientIp,
'X-Signature': signature,
};
if (customerToken) headers.Authorization = `Bearer ${customerToken}`;
// body here is the same string that was signed
const res = await fetch('https://wallet.apw.ge' + path, { method: 'POST', headers, body });
return { status: res.status, body: await res.json() };
}import hashlib, hmac, json, os, time, uuid
import requests
def apw_request(path, data, client_ip, customer_token=None):
body = json.dumps(data or {}, ensure_ascii=False).encode('utf-8')
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
string_to_sign = '\n'.join([
'POST', path, timestamp, nonce, client_ip, hashlib.sha256(body).hexdigest(),
])
signature = hmac.new(os.environ['APW_SECRET'].encode(), string_to_sign.encode(), hashlib.sha256).hexdigest()
headers = {
'Content-Type': 'application/json',
'X-Partner-Id': os.environ['APW_PARTNER_ID'],
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Client-Ip': client_ip,
'X-Signature': signature,
}
if customer_token:
headers['Authorization'] = 'Bearer ' + customer_token
# data=body, not json=data, so that exactly the signed bytes are sent
res = requests.post('https://wallet.apw.ge' + path, data=body, headers=headers, timeout=140)
return res.status_code, res.json()PATH_="/api/v1/register/params"
BODY='{}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
CLIENT_IP="203.0.113.7"
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
SIG=$(printf 'POST\n%s\n%s\n%s\n%s\n%s' "$PATH_" "$TS" "$NONCE" "$CLIENT_IP" "$BODY_HASH" \
| openssl dgst -sha256 -hmac "$APW_SECRET" -hex | awk '{print $NF}')
curl -sS "https://wallet.apw.ge$PATH_" \
-H "Content-Type: application/json" \
-H "X-Partner-Id: $APW_PARTNER_ID" \
-H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" \
-H "X-Client-Ip: $CLIENT_IP" \
-H "X-Signature: $SIG" \
--data-binary "$BODY"Additional protection
- IP allowlist: give us the IP addresses of your server(s), and we will accept requests only from them.
- Nonce: the same
X-Nonceis not accepted twice (error 905). This makes replaying an intercepted request impossible. When retrying, always use a new nonce and a new signature. - Time: keep your server clock synchronized via NTP. With a difference of more than ±300 seconds you will get error 904.
Customer authentication
The access_token comes from three endpoints: auth/login, register/4 (completing registration) and password/reset/change. Send it in the Authorization: Bearer <token> header, together with the partner HMAC headers.
- It is valid for 1 hour. There is no refresh token. After it expires, the customer must log in again.
- An account has one active session. Logging in or resetting the password revokes previous tokens, including those in APW's own app. So a token can be revoked at any time, and you must handle 401 correctly.
- An invalid or expired token gets HTTP
401with the body{"message":"Unauthenticated."}. Sometimes the body is{"errorCode":401,"errorMessage":"Token expired"}. In both cases, send the customer to the login screen. - Exception:
billing/pay,transfer/payandexchange/convertreturn HTTP 401 with the text „ავტორიზაცია მუშავდება“ ("authorization is being processed") or „…ავტორიზაცია დაიბლოკა“ ("…authorization has been blocked"). This is not a token problem: the operation is undergoing an additional check. Show the text to the customer and try again later. A real token problem is always answered with"Unauthenticated."or"errorCode":401.
Responses and errors
Gateway errors (9xx)
The request did not reach the wallet. The format is {"errorCode": 9xx, "errorMessage": "..."}.
| errorCode | HTTP | Reason |
|---|---|---|
| 901 | 400 | A header is missing or has an invalid format |
| 902 | 401 | Partner ID is unknown or disabled |
| 903 | 403 | The request came from a disallowed IP address |
| 904 | 401 | X-Timestamp is outside the ±300 second window |
| 905 | 401 | X-Nonce has already been used |
| 906 | 401 | The signature does not match |
| 907 | 429 | The per-minute request limit has been exhausted |
| 908 | 404 | No such endpoint exists |
| 910 | 502 | The wallet service is temporarily unavailable |
| 911 | 504 | The wallet service did not respond in time. The request may still have been executed, so check the result before retrying. |
Wallet responses
After the signature check, the wallet response is returned unchanged: both the status and the body. The body comes in two formats:
| Group | Success | Error |
|---|---|---|
| auth/*, password/reset/*, two-factor/* | HTTP 200, errorCode: 0 | HTTP 200, errorCode ≠ 0 and errorMessage |
| register/*, user/password | HTTP 200 | HTTP 402, {"message": "..."} |
| billing/pay, billing/info, transfer/* | HTTP 200 | HTTP 402, errorCode + errorMessage or {"message": "..."} |
| user, user/transactions, user/limits, billing catalog | HTTP 200, data directly (without a wrapper) | 401 / 402 |
| user/services, user/notifications/*, exchange/* | HTTP 200, errorCode: 0 and data | HTTP 402, errorCode + errorMessage (message on validation errors) |
| verification/*, verification-defect/* | HTTP 200 | HTTP 402, {"message": "..."} |
| requested-document | HTTP 200, errorCode: 0 | HTTP 200, errorCode ≠ 0 |
| qr/verify | HTTP 200, errorCode: 0 | HTTP 401 / 402, errorCode + errorMessage or {"message": "..."} |
Read the error text in this order: errorMessage, then message. The text (errorMessage / message) is intended to be shown to the customer. It is mostly in Georgian, some of it in English. Build your logic on errorCode and the HTTP status, not on the text.
Limits
- 600 requests per minute per partner (can be raised by agreement). When exceeded: 907 / HTTP 429.
- The wallet's own limits. When these are exceeded: HTTP 429 with the body
{"message":"Too Many Attempts."}and aRetry-Afterheader. - An SMS code can be resent at most once per minute per account.
verification-defect/2sends an SMS on every call, so 5 calls per 10 minutes are allowed per customer. When exceeded: HTTP 429,{"message":"Too Many Attempts."}.- After 10 wrong passwords within 15 minutes, logging in to the account is temporarily blocked.
- After 5 wrong codes for one token, the login process must be started over.
- Request timeout on your side: at least 140 seconds for regular endpoints, and at least 310 seconds for
billing/pay,transfer/pay,exchange/convert,qr/verifyandverification/2. Before a payment the system performs several external checks, and a connection closed early does not cancel the result. - Payments (billing) are unavailable every day from 23:30 to 00:10 (Tbilisi time):
errorCode 99.
Registration: flow
Registration consists of several steps. Each step uses the token from the previous one (a 64-character string; this is not the access_token).
Two steps take place on the customer's own device and do not go through the API:
- document scan and selfie (Identomat);
- signing the contract via a link received by SMS.
- register/params: lists for the form (countries, employment, income…).
- register/1: contact details, address, password, PEP status. Returns a
token. - register/2: economic profile. Returns an
iframe_url. - The customer opens the
iframe_urlon their device (a camera is required) and completes the document scan and selfie. - register/3: we check the document, and the customer receives two SMS messages: the contract link and a 6-digit code.
- The customer signs the contract via the link. The link is valid for 10 minutes.
- register/check: poll until
status: 1is returned. If the link has expired, use register/contract-sms. - register/4: registration is completed with the SMS code. Returns an
access_token.
Restrictions:
- Registration is currently available only to citizens of Georgia.
- The customer must be at least 18 years old.
- An ID card or a passport is accepted as the document.
POST/api/v1/register/params
Allowed values for the form fields. The body is empty: {}.
{
"message": "Success",
"data": {
"0": {
"countrie": [{"ccode": "GE", "country": "Georgia", "code": "995", ...}],
"sferos": [...],
"userEmployeeStatus": [...],
"userExpectedTurnover": [...],
"userMonthlyIncome": [...],
"userPurposeRelationship": [...],
"userSourceOfIncome": [...],
"currency_id": [{"id": 981, "name": "GEL"}, {"id": 978, "name": "EUR"}, {"id": 840, "name": "USD"}, {"id": 643, "name": "RUB"}]
},
"links": {"contract": "https://…pdf", "privacy_policy": "https://…pdf", "limits": "https://…pdf"},
"pep_status": [...], "pep_family_status": [...],
"pep_definition": "...", "pep_family_definition": "..."
}
}
The lists are under data["0"] (the key is the string "0").
POST/api/v1/register/1
| Field | Rule |
|---|---|
mobile | Required. 9–15 digits, including the country code (995555123456). Must be unique. |
email | Required. A valid and unique email address. |
legal_address | Required. 3–255 characters. |
address_same | Required. 1: the actual address matches the legal address; 0: it does not. |
real_address | Required if address_same = 0. 3–255 characters. |
password | Required. At least 8 characters, containing both letters and digits. |
confirm_password | Required. Must match password. |
pep_status | Required. 0 or 1: whether the person is a politically exposed person. |
pep_title | Required if pep_status = 1. 3–255 characters. |
pep_family_status | Required. 0 or 1. |
pep_family | Required if pep_family_status = 1. 3–255 characters. |
limits, privacy_policy, contract | Required. All three must be 1: the customer has agreed to the documents given in links. |
// 200
{"token": "Xr9…64 characters", "message": "Success"}
// 402
{"message": "ელ-ფოსტა უკვე გამოყენებულია"}
POST/api/v1/register/2
| Field | Rule |
|---|---|
token | The token from register/1 |
dual_citizen | 1: has dual citizenship; 2: does not |
country2 | Required if dual_citizen = 1. 2-letter country code (ccode). |
employee_status | ID from userEmployeeStatus |
job_title, occupied_position | 3–100 characters. Both are required when employee_status = 1. When = 2, only occupied_position. |
source_of_income_id | Required if employee_status = 3. ID from userSourceOfIncome. |
company_name, company_country | Required if employee_status is 4 or 5. company_country is a 2-letter code. |
monthly_income | ID from userMonthlyIncome |
expected_turnover | ID from userExpectedTurnover |
purpose_id | ID from userPurposeRelationship |
currencies | Comma-separated currencies from 981,840,978,643, e.g. "981,840" |
// 200
{"token": "…", "iframe_url": "https://widget.identomat.com/?session_token=…", "message": "Success"}
Open the iframe_url for the customer: in a WebView with camera permission, or in a browser. When the customer finishes the scan, call register/3.
POST/api/v1/register/3
Field: token.
We check the Identomat result, the validity of the document and the age. On success the customer receives two SMS messages: the contract link and a 6-digit code for register/4. This step takes a few seconds.
// 200
{"token": "…", "mobile": "995******123", "expire": 5, "message": "Success"}
// 402: examples
{"message": "user_checking"} // the document is being checked manually, try again later
{"message": "user must be 18 years or older!"}
{"message": "არარეზიდენტების რეგისტრაცია დროებით შეზღუდულია!"}
POST/api/v1/register/check
Field: token. Checks whether the customer has signed the contract. Poll once every few seconds.
// 200: signed
{"status": 1, "message": "ხელმოწერილი"}
// 402: not signed yet. This is a normal state, not an error.
{"status": 0, "message": "ხელმოუწერელი"}
POST/api/v1/register/contract-sms
Field: token. Resends the contract link. Call it only when the previous link has expired, i.e. 10 minutes after it was sent.
// 200
{"token": "…", "mobile": "99*****23", "expire": 5, "message": "Success"}
POST/api/v1/register/4
| Field | Rule |
|---|---|
token | The token from register/3 |
sms | 6 digits: the code from the register/3 SMS |
// 200
{
"message": "რეგისტრაცია წარმატებით დასრულდა",
"user": {"id": 1, "verify_id": 1, "iban": "GE…", "wallet_number": "12345678901",
"first_name": "…", "last_name": "…", "birth_date": "1990-01-01",
"balance": 0, "balance_usd": 0, "balance_eur": 0, "balance_rub": 0,
"mobile": "995…", "email": "…"},
"access_token": "eyJ0eXAiOiJKV1Qi…"
}
// 402
{"message": "ხელშეკრულებაზე დათანხმება აუცილებელია!"}
Login: flow
- auth/login-check: wallet number and password. The
two_factorfield in the response tells you which code is required. two_factor: "sms": the customer receives an SMS code. To resend it, use auth/login-sms.
two_factor: "google": the code is in the customer's Authenticator app. No SMS is sent.- auth/login: token and code. Returns an
access_token.
Legal entity (type = 2): one natural person can have several companies linked to them. auth/login-check returns a legal_entities list; if it contains more than one entry, you must pass legal_user_id to auth/login. With a single company nothing changes.
POST/api/v1/auth/login-check
| Field | Rule |
|---|---|
wallet_number | Required. 11 digits. |
password | Required |
type | Optional. 1: natural person (default); 2: log in on behalf of a legal entity that the customer manages. |
// SMS
{"two_factor": "sms", "phone": "995******123", "token": "…", "date": "2026-10-22 12:00:00",
"expire": 1, "errorCode": 0, "message": "Sms send success"}
// Authenticator
{"two_factor": "google", "token": "…", "date": "…", "expire": 5, "errorCode": 0,
"message": "Authenticator code required"}
expire is the code's validity period in minutes.
For type = 2, the response additionally contains legal_entities — the list of companies this customer can log in as. This field is absent from the response for a natural-person login.
{"two_factor": "sms", "phone": "995******123", "token": "…", "expire": 1,
"errorCode": 0, "message": "Sms send success",
"legal_entities": [
{"legal_user_id": 12, "wallet_number": "00000000123",
"name": "შპს ალფა", "identification_number": "400147211"},
{"legal_user_id": 19, "wallet_number": "00000000456",
"name": "შპს ბეტა", "identification_number": "405123456"}
]}
The list includes only the companies that can be logged in as at this moment — expired or suspended authority and blocked companies are excluded. name is the company name, identification_number is its identification code; both are meant to be shown to the customer, while legal_user_id is what you pass to auth/login.
| errorCode | Meaning |
|---|---|
| 1 | Validation error |
| 2, 4 | Wrong wallet number or password, or the number of attempts has been exhausted |
| 3 | The authorization is undergoing an additional check or is blocked. Show the text to the customer and try again later. |
| 5 | An SMS was sent just now. Try again in 1 minute. |
| 6 | Legal entity not found (type = 2) |
| 8 | Companies are linked, but none can be logged in as at this moment (type = 2): the power of attorney has expired, is not specified, has not started yet, or the authority is suspended/revoked. Logging in as a natural person is still possible. |
POST/api/v1/auth/login-sms
Field: token (from login-check). Resends the SMS code, at most once per minute. The token does not change.
{"phone": "995******123", "token": "…", "date": "…", "expire": 1, "errorCode": 0, "errorMessage": "Sms send success"}
If errorCode: 6 is returned, the customer uses an Authenticator app and no SMS is sent.
POST/api/v1/auth/login
| Field | Rule |
|---|---|
token | The token from login-check |
sms | 6-digit SMS code. Valid for 1 minute. |
code | 6-digit Authenticator code, when two_factor: "google". Must be used within 5 minutes of login-check. |
legal_user_id | Optional. Which legal entity to log in as — from login-check's legal_entities. Required only when the list contains more than one entry; omit it if there is only one. |
{
"user": {"id": 1, "verify_id": 1, "iban": "GE…", "wallet_number": "12345678901",
"first_name": "…", "last_name": "…", "birth_date": "…", "mobile": "…", "email": "…",
"balance": 10.5, "balance_usd": 0, "balance_eur": 0, "balance_rub": 0,
"frozen_balance": 0, "frozen_balance_usd": 0, "frozen_balance_eur": 0, "frozen_balance_rub": 0,
"type": 1, "verification_defect": 0, "requested_document": 0, ...},
"access_token": "eyJ0eXAiOiJKV1Qi…",
"errorCode": 0,
"errorMessage": "success"
}
| errorCode | Meaning |
|---|---|
| 1 | Validation error |
| 2 | The code is wrong or expired, or the attempts have been exhausted. In the last case, start the login over from login-check. |
| 5 | Legal entity not found |
| 7 | Authenticator code attempts exhausted |
| 8 | The authority ended after login-check (see login-check, code 8) |
| 9 | Choose a legal entity: you did not send legal_user_id and the list has more than one company, or the id you sent does not belong to this customer. The response includes legal_entities. |
On code 9 the token remains valid — show the choice and call again with the same token and code. The login does not start over.
// 200 — choose a company
{"errorCode": 9, "errorMessage": "აირჩიეთ იურიდიული პირი",
"legal_entities": [
{"legal_user_id": 12, "wallet_number": "00000000123",
"name": "შპს ალფა", "identification_number": "400147211"},
{"legal_user_id": 19, "wallet_number": "00000000456",
"name": "შპს ბეტა", "identification_number": "405123456"}
]}
A successful login revokes all other sessions of this account. For a legal entity this also means that two authorized persons of the same company cannot be logged in at the same time — the second one's login logs out the first.
POST/api/v1/auth/logoutcustomer token
The body is empty. Revokes the customer's tokens.
{"errorCode": 0, "errorMessage": "success"}
Password reset: flow
- password/reset/check: wallet number. The customer receives an SMS code.
- password/reset/verify: the code. Returns a new token.
- password/reset/change: the new password, with the new token, within 10 minutes. The customer is logged in directly.
password/reset/check returns success even for an unknown wallet number. This is intentional, so that the API cannot be used to find out whether a wallet exists. In that case the next step will return a “wrong code” error.
POST/api/v1/password/reset/check
Field: wallet_number (11 digits).
{"phone": "995******123", "token": "…", "date": "…", "expire": 1, "errorCode": 0,
"errorMessage": "Password reset sms send success"}
Errors: 1 validation · 3 authorization is being checked or is blocked · 4 an SMS was sent just now.
POST/api/v1/password/reset/sms
Field: token. Resends the SMS, at most once per minute. The response format is the same as for reset/check.
POST/api/v1/password/reset/verify
| Field | Rule |
|---|---|
token | The token from reset/check |
sms | 6 digits. Valid for 1 minute. |
{"token": "…new token…", "date": "…", "errorCode": 0, "errorMessage": "SMS check success"}
Errors: 1 validation · 2 the code is wrong or attempts are exhausted · 3 the code has expired.
POST/api/v1/password/reset/change
| Field | Rule |
|---|---|
token | The new token from reset/verify |
password | At least 8 characters, containing both letters and digits |
confirm_password | Must match password |
The success response is the same as for auth/login: user and access_token. Errors: 1 validation · 2 the token is wrong or already used · 3 the token has expired (10 minutes).
Payments: flow
Paying for services from the customer's wallet: mobile, utilities, internet, etc. The catalog is public (no customer token needed), while paying requires a token.
- billing/categories → billing/category/{id} → billing/service/{id}: choosing a service. The
servicecontains the field descriptions (params_info,params_pay), the amount limits and the commission. - billing/info: checking the subscriber (debt, name…). The
smsfield in the response tells you whether the payment needs an SMS code. - If
sms = 1: billing/pay-sms. The code is valid for 2 minutes. - billing/pay: the payment. On success you receive an
operationId. - billing/status/{operationId}: poll until the status becomes
3(completed) or4(cancelled).
Payment is asynchronous. Success of billing/pay means the payment has been accepted. At that moment the amount is held on the wallet, and it is debited on completion, usually within a few seconds. Check the final result with billing/status.
If you receive the response „ოპერაცია მუშავდება…“ ("operation in progress…") or 911 / a timeout, do not repeat the payment. The result is unknown and the payment may already have been executed. Check user/transactions: the entry will appear there as „მიმდინარე: …“ ("in progress: …"). Repeating it will cause a double payment.
The currency is always the lari (GEL). An identical payment (same service, subscriber and amount) is not accepted a second time within 20 seconds.
Catalog
POST/api/v1/billing/categories: array of categories.
[{"id": 32, "image": "https://uploads.allpayway.ge/files/categories/….png", "count": 3,
"lang": {"ka": "მობილური", "en": "Mobile", "ru": "Мобильная связь"}}, ...]
POST/api/v1/billing/category/{id}: the services of a category.
{"services": [{"id": 19, "name": "Magti", "lang": {...}, "image": "…", "min_amount": "1.00", "max_amount": "400.00", ...}],
"category": {"lang": {"ka": "…", "en": "…", "ru": "…"}}}
POST/api/v1/billing/services: all services in a single array.
POST/api/v1/billing/service/{id}: full description of a single service.
{
"errorCode": 1000,
"service": {
"id": 19, "name": "Magti", "lang": {"ka": "მაგთი", "en": "Magti", "ru": "Магти"},
"currency_id": 981, "min_amount": "1.00", "max_amount": "400.00", "category_id": 32,
"image": "https://uploads.allpayway.ge/files/services/….png",
"params_info": [
{"name": "phone_number", "regexp": "/^(\\d{9})$/i", "description": "ტელეფონის ნომერი", "example": "599470076"}
],
"params_pay": [
{"name": "phone_number", "regexp": "/^(\\d{9})$/i", "description": "ტელეფონის ნომერი", "example": "599470076"}
],
"commission": {
"client_commission_percent": "4.00", "client_commission_fixed": "0.00",
"min_client_commission": "0.000000", "max_client_commission": "0.000000",
"min_amount": "1.000000", "max_amount": "1000.000000", "rate": 1
}
}
}
params_info: the fields for billing/info.params_pay: the fields for billing/pay. Each field's name (name) is the request field name. The value must matchregexp. Usedescriptionandexamplein your form.commission: the customer commission (percentage, fixed, minimum and maximum). The final commission is returned in the payment response and in the status.
POST/api/v1/billing/param: description of a single field. Body: {"param": "phone_number"}.
POST/api/v1/billing/info
Checking the subscriber. Fields: service_id and every params_info field of the service.
// request
{"service_id": 19, "phone_number": "599470076"}
// 200: the fields depend on the service. "sms" is always present.
{"errorCode": 1000, "errorMessage": "წარმატებული", ..., "sms": 1}
// 402
{"message": "The phone number field is required."}
{"errorCode": 99, "errorMessage": "00:10 საათამდე გადახდას ვერ შეძლებთ"}
POST/api/v1/billing/pay-smscustomer token
The customer receives a 6-digit code to confirm the payment. The code is valid for 2 minutes. The body is empty.
{"message": "Success"}
A customer has one active SMS code at a time. It is shared between payment, transfer, login and verification-defect/2. Every new send revokes the previous code. Request the code immediately before pay.
POST/api/v1/billing/paycustomer token
| Field | Rule |
|---|---|
service_id | Service ID |
The service's params_pay fields | Each with its own regexp |
amount | Amount in GEL, with at most 2 decimal places and greater than 0 ("10", "10.50"). Within the service's min_amount/max_amount. |
sms | 6-digit code, if billing/info returned sms: 1 |
// request
{"service_id": 19, "phone_number": "599470076", "amount": "10.00", "sms": "123456"}
// 200: payment accepted
{"errorCode": 1000, "errorMessage": "წარმატებული", "operationId": 987654,
"accoutant": {"genAmount": 10.4, "clientCommission": 0.4}, ...}
Errors (HTTP 402):
| Response | Meaning |
|---|---|
errorCode 9 | Payment rejected: insufficient balance, limit exhausted, SMS code wrong or expired, customer not verified, or the same payment was sent just now. The reason is in errorMessage. |
errorCode 9 + „ოპერაცია მუშავდება…“ | The result is unknown. Do not repeat. Check user/transactions. |
errorCode 8 | The customer's ID document has expired. It needs to be renewed. |
errorCode 99 | Nightly break, 23:30–00:10 |
Other errorCode | Rejected by the provider. The amount was not debited. |
{"message": "..."} | Field validation or document check error |
| HTTP 401 „ავტორიზაცია მუშავდება“ | An additional check is in progress. Try again later. The SMS code remains valid within its 2 minutes. |
POST/api/v1/billing/status/{operationId}customer token
operationId from the billing/pay response. The body is empty. The response is always HTTP 200.
{"errorCode": 0, "errorMessage": "success", "data": {
"service": {"image": "…", "lang": {"ka": "მაგთი", "en": "Magti", "ru": "Магти"},
"abonent": "599470076", "amount": "10.00", "gen_amount": "10.40", "client_comision": "0.40"},
"status": {"status_id": 3, "description": "შესრულებული"}}}
| status_id | Meaning | What to do |
|---|---|---|
| 1 | Registered | Poll again |
| 2 | In progress | Poll again |
| 3 | Completed | Final: the amount was debited |
| 4 | Cancelled | Final: the amount was released |
| 5 | Pending confirmation | Processed manually, contact us |
If the payment is not found, service and status are empty arrays ([]).
Poll once every 5–10 seconds.
Transfers: flow
From an APW wallet to an APW wallet, instantly. All endpoints require a customer token.
- transfer/info: checking the recipient (by wallet number or IBAN) and the commission. Optional.
- transfer/pay-sms: confirmation code via SMS. Valid for 2 minutes.
- transfer/pay: the transfer.
POST/api/v1/transfer/infocustomer token
| Field | Rule |
|---|---|
type | 1: by wallet number; 2: by IBAN |
wallet_number | 11 digits (when type = 1) |
iban | In the format GE00DL0000000000000000 (when type = 2) |
// 200
{"errorCode": 0, "errorMessage": "Success",
"data": {"reciver": "გ. ბ.", "iban": "GE00DL0000000000000000", "percents": ...}}
reciver: the recipient's initials, so the customer can double-check. percents: the transfer commission.
Errors (402): 1 validation · 2 service restricted · 3 recipient not found · 4 transfer to own wallet · 22 temporary technical issue.
POST/api/v1/transfer/pay-smscustomer token
Same as billing/pay-sms: a 6-digit code valid for 2 minutes, and it is the same shared code (see note). Response: {"message": "Success"}.
POST/api/v1/transfer/paycustomer token
| Field | Rule |
|---|---|
type, wallet_number / iban | As in transfer/info |
amount | At most 2 decimal places ("25.50") |
currency_id | 981 GEL · 840 USD · 978 EUR · 643 RUB |
destination | Purpose of the transfer (text) |
sms | 6-digit code from transfer/pay-sms |
// request
{"type": 1, "wallet_number": "12345678901", "amount": "25.50", "currency_id": 981,
"destination": "ვალის დაბრუნება", "sms": "123456"}
// 200: transfer completed
{"errorCode": 0, "errorMessage": "Success", "data": {...}}
Errors (HTTP 402, errorCode):
| errorCode | Meaning |
|---|---|
| 1 | Validation |
| 2–9 | Sender check failed: restriction, document, limit (9) |
| 10–18 | Recipient check failed |
| 19 | Insufficient balance |
| 20 | Wrong SMS code |
| 21 | SMS code expired |
| 22 | The result is unknown (the transfer service did not respond). Do not repeat; check user/transactions first. |
| HTTP 401 „ავტორიზაცია მუშავდება“ | Additional check. Try again later. |
Customer
POST/api/v1/usercustomer token
The customer's current data and balances. The body is empty. The response is an object without a wrapper:
{"id": 1, "iban": "GE…", "wallet_number": "12345678901", "first_name": "…", "last_name": "…",
"birth_date": "…", "mobile": "…", "email": "…",
"balance": 10.5, "balance_usd": 0, "balance_eur": 0, "balance_rub": 0,
"frozen_balance": 0, "frozen_balance_usd": 0, "frozen_balance_eur": 0, "frozen_balance_rub": 0,
"avatar": null, "verify_id": 1, "type": 1,
"verification_defect": 0, "requested_document": 0}
POST/api/v1/user/transactionscustomer token
Operation history, newest first. All filters are optional. Do not send a field you are not using at all (an empty string causes a validation error).
| Field | Rule |
|---|---|
from_date, to_date | Y-m-d, both days inclusive |
from_amount, to_amount | Amount range |
currency_id | 981 / 840 / 978 / 643 |
limit | Number of entries, 10 by default. Please do not request more than 100. |
[{"id": 1201, "date": "2026-10-22 12:00:05", "description": "Magti: 599470076",
"amount": 10.4, "type": 0, "balance": 89.6, "currency_id": 981, "currency": "GEL",
"service_id": 19, "abonent": "599470076", "gen_amount": "10.40", "client_comision": "0.40", "status_id": 3}, ...]
type:1incoming,0outgoing.- Unfinished payments start with „მიმდინარე: …“ ("in progress: …"), cancelled ones with „გაუქმებული ოპერაცია: …“ ("cancelled operation: …"). Their
balanceisnull. idcomes from different tables and is not unique within the history. Do not use it as an entry key.
POST/api/v1/user/passwordcustomer token
| Field | Rule |
|---|---|
old_password | Current password |
new_password | At least 8 characters, containing both letters and digits |
confirm_password | Must match new_password |
// 200
{"message": "პაროლი წარმატებით შეიცვალა"}
// 402
{"message": "ძველი პაროლი არასწორია!"}
POST/api/v1/user/language/{locale}customer token
locale: ge, en or ru. Stores the customer's preferred language (used on payment pages). The body is empty.
{"message": "success"}
POST/api/v1/user/limitscustomer token
The customer's spending limits in GEL: monthly and daily. The body is empty. The response has no wrapper.
// natural person
{"month": "5000.00", "day": "1500.00"}
// legal entity
{"month": "unlimited", "day": "unlimited"}
Read both fields as strings: they can be a number or "unlimited".
POST/api/v1/user/servicescustomer token
Services payable with the wallet, grouped by category, with amount limits and the customer commission. The body is empty. For a full service description (fields, logo, names in each language) use billing/service/{id}.
{"errorCode": 0, "errorMessage": "success", "data": [
{"category_id": 32, "services": [
{"service_id": 19, "name": "Magti", "min_amount": 1.0, "max_amount": 400.0,
"client_commission_percent": 4.0, "client_commission_fixed": 0.0,
"min_client_commission": 0.0, "max_client_commission": 0.0}, ...]}, ...]}
If there are no entries: HTTP 402, {"errorCode": 1, "errorMessage": "ჩანაწერები ვერ მოიძებნა"}.
POST/api/v1/user/kyccustomer token
The customer's KYC documents (the questionnaire and its confirmation). The body is empty.
{"errorCode": 0, "errorMessage": "success", "data": [
{"id": 123, "file": "https://…/files/kyc/…?expires=…&sig=…", "date": "2026-10-22T10:00:00.000000Z"}, ...]}
file is a signed link, valid for 1 hour. Do not store it: call the endpoint again when you need it. These are personal documents; show them to no one but the customer. If there are no documents, data is an empty array.
Notificationscustomer token
APW notifications for the customer. The body is empty for all endpoints. Notification format:
{"id": 5, "user_id": 42, "type_id": 1, "seen": 0, "seen_at": null,
"title": "…", "text": "…", "created_at": "…", "updated_at": "…"}
| Endpoint | Returns |
|---|---|
/api/v1/user/notifications/next | One unread notification in data. If there is none, data is null. Order is not guaranteed. |
/api/v1/user/notifications/new | All unread ones, as an array |
/api/v1/user/notifications/all/{limit} | The last limit notifications, newest first, including read ones. limit: 1–100. |
/api/v1/user/notifications/seen/{id} | Marks the notification as read |
{"errorCode": 0, "errorMessage": "success", "data": [ … ]}
seen/{id} always returns {"errorCode": 0, "errorMessage": "success"}, even when the notification does not exist or belongs to another customer (someone else's notification is not changed). next does not mark the notification as read.
Two-factor authentication (Authenticator)customer token
By default the login code is sent via SMS. The customer can link Google Authenticator or another TOTP app.
All endpoints return HTTP 200. On success: {"errorCode": 0, "errorMessage": "success", "data": {...}}.
Current state (data):
{"method": "sms" | "google", "method_id": 1 | 2, "google_enrolled": true,
"google_confirmed_at": "2026-10-22 12:00:00" | null, "google_setup_pending": false}
| Endpoint | Fields | What it does |
|---|---|---|
/api/v1/two-factor | — | Returns the current state |
/api/v1/two-factor/google/setup | — | Starts linking. data: secret, otpauth_uri, digits, period. Show otpauth_uri as a QR code. |
/api/v1/two-factor/google/confirm | code (6 digits, from the app) | Confirms linking. After this, login requires the Authenticator code. |
/api/v1/two-factor/google/cancel | — | Cancels an unfinished linking |
/api/v1/two-factor/google/disable | code | Disables the app, and login goes back to SMS |
/api/v1/two-factor/method | method: sms or google; code, if switching from google to sms | Changes the login method |
Errors: 1 validation · 2 the code is wrong or expired · 3 the app is not linked.
Currency
Exchange rates and currency exchange between wallet balances. All endpoints require a customer token.
POST/api/v1/exchange/currenciescustomer token
The body is empty.
{"errorCode": 0, "errorMessage": "Success", "data": [
{"id": 981, "title": "GEL"}, {"id": 840, "title": "USD"},
{"id": 978, "title": "EUR"}, {"id": 643, "title": "RUB"}]}
RUB (643) is in the list, but a rate cannot be obtained for it (errorCode 2).
POST/api/v1/exchange/ratecustomer token
How much the customer would receive when exchanging amount. No money is moved.
| Field | Rule |
|---|---|
from | Source: 3-digit currency code (981, 840, 978) |
to | Target: 3-digit currency code |
amount | Amount in the from currency, with at most 2 decimal places |
// request
{"from": 981, "to": 840, "amount": 100}
// 200
{"errorCode": 0, "errorMessage": "Success",
"data": {"from": "GEL", "to": "USD", "amount": 100, "rate": 36.42}}
data.rate is the amount to be received in the to currency, not the unit rate.
| errorCode (HTTP 402) | Meaning |
|---|---|
| 9 | Validation. The text is in message. |
| 1 | A parameter is missing |
| 2 | The currency is invalid or is RUB, there is no rate for today |
| 3 | Customer not found |
| 4 | Customer is not verified |
| 5 | Customer is blocked |
| 6 | AML restriction |
| 7 | Sanctions screening failed for an amount over 2500 |
POST/api/v1/exchange/convertcustomer token
Performs the exchange: the amount is debited from the from balance and credited to the to balance. Fields and the success response are the same as for exchange/rate. data.rate is the amount credited.
// request
{"from": 981, "to": 840, "amount": 100}
// 200: exchange completed
{"errorCode": 0, "errorMessage": "Success",
"data": {"from": "GEL", "to": "USD", "amount": 100, "rate": 36.42}}
No SMS code is required: have the customer confirm the exchange in your application before you call it.
Do not retry on 911 / timeout. The outcome is unknown: check user/transactions first. While an exchange is still being processed, a new exchange for the same customer is answered with errorCode 11.
| Response | Meaning |
|---|---|
402, errorCode 1–7 | As for exchange/rate |
402, errorCode 8 | Amount below 1, insufficient balance, or the customer has no valid document |
402, errorCode 9 | Validation (the text is in message) or a write failure |
402, errorCode 11 | A previous exchange for this customer is still being processed. Try again later. |
402, {"message": "..."} | The document check failed, or the document is cancelled or suspended |
| 401 „ავტორიზაცია მუშავდება“ | Additional check (see the exception). Try again later. |
Verification: what to use when
The user response contains three fields that tell you what the customer needs to do:
| Situation | What to use |
|---|---|
The ID document has expired (billing/pay returns errorCode 8) or a new document is required | verification/1 → verification/2: re-scan of the document and a selfie |
verification_defect: 1 | verification-defect/1 → 2 → 3: filling in the missing data |
requested_document: 1 | requested-document: uploading the requested image. Which image is needed is written in requested_document_comment. |
POST/api/v1/verification/{step}customer token
step: 1 or 2. The process resembles the document step of registration (register/2–3).
- verification/1: the body is empty. Returns a
tokenand aniframe_url.{"token": "…64 characters", "iframe_url": "https://widget.identomat.com/?session_token=…", "message": "Success"} - Open the
iframe_urlfor the customer (a camera is required). The widget language is Georgian. Ifsession_tokenin the URL is empty, the service is temporarily unavailable: try again later. - verification/2: field
token. Checks the document and saves it. This may take several minutes: set a timeout of at least 310 seconds.// 200 {"message": "Verification completed Success"} // 402: examples {"message": "user_checking"} // the document is being checked manually, try again later {"message": "არასწორი დოკუმენტი!"} // the document belongs to another person {"message": "SESSION_NOT_FOUND"} // the token is wrong or the scan was not completed
The personal number on the document must match the customer's personal number.
POST/api/v1/verification-defect/{step}customer token
step: 1, 2 or 3. Use it when user has verification_defect: 1: data is missing from the customer's profile form.
verification-defect/1: the body is empty. Returns which fields need to be filled in.
// needs filling in
{"errorCode": 1, "errorMessage": "შესავსებია გამოტოვებული ველები",
"data": {"employee_status": …, "real_address": …}}
// nothing is missing
{"errorCode": 0, "errorMessage": "ვერიფიცირებული"}
The keys of data come from this list: employee_status, monthly_income, expected_turnover, address_same, job_title, occupied_position, source_of_income_id, company_name, company_country, real_address, legal_address. Both responses are HTTP 200.
verification-defect/2: send only the fields returned by step 1. The value lists are in register/params.
| Field | Rule |
|---|---|
employee_status, monthly_income, expected_turnover | ID from the corresponding list |
job_title, occupied_position | 3–100 characters. Both are required when employee_status = 1; when = 2, only occupied_position. |
source_of_income_id | Required if employee_status = 3 |
company_name, company_country | Required if employee_status is 4 or 5. company_name 2–100 characters, company_country a 2-letter code. |
legal_address, real_address | 3–255 characters |
address_same: 1 if the actual address matches the legal address; 0 if it does not, in which case real_address is required.
// 200: the customer receives an SMS with the profile form link and a 6-digit code
{"token": "…64 characters", "message": "Success"}
// 402
{"message": "…validation text…"}
{"message": "ატვირთული დოკუმენტი არავალიდურია!"} // complete verification/1–2 first
Every call sends a new SMS and revokes the previous code (see the note on the shared code). Limit: 5 calls per 10 minutes per customer.
verification-defect/3: confirms the filled-in data.
| Field | Rule |
|---|---|
token | The token from step 2 |
sms | 6-digit code from the SMS |
// 200
{"message": "Success"}
// 402
{"message": "sms-ით მიღებული კოდი არასწორია"}
After success, verification_defect in user becomes 0.
POST/api/v1/requested-documentcustomer token
Use it when user has requested_document: 1. APW has requested an image from the customer: which image is written in requested_document_comment.
| Field | Rule |
|---|---|
file | The image as a data URI: data:image/png;base64,…. JPEG, PNG or GIF, at most 10 MB (before base64). |
This is a regular JSON request, not multipart. The signing rule is the same.
{"file": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ…"}
The response is always HTTP 200. Determine the result from errorCode:
| errorCode | Meaning |
|---|---|
| 0 | The image was uploaded |
| 1 | Invalid file: not a base64 image, or the format or size does not match |
| 2 | No image has been requested from this customer |
ATM cash withdrawal (QR)
POST/api/v1/qr/verifycustomer token
The customer scans the QR code on the ATM screen and takes cash from the wallet. This endpoint binds the ATM session to the customer's wallet.
| Field | Rule |
|---|---|
sessionId | Required. The ATM session, from the QR code. |
atmId | Required. The ATM identifier, from the QR code. |
// 200
{"errorCode": 0, "errorMessage": "Success"}
No SMS code is required: before calling, have the customer confirm in your application that they are standing at the ATM themselves. The request can take several minutes.
| Response (HTTP 402) | Meaning |
|---|---|
errorCode 9 | The customer is blocked, not verified, or under an AML restriction |
errorCode 8 | No valid document, or the QR data is invalid |
other errorCode | The ATM service's answer. The text is in errorMessage. |
{"message": "..."} | Field validation or a document check failure |