Atlas Online merchant API
Collect pay-ins, send pay-outs, handle disputes and manage your USDT balance from your own server. Each call answers synchronously, final outcomes arrive as signed webhooks, and every request carries an HMAC-SHA256 signature.
Quickstart
Your first pay-in takes four steps. The product tour is on the developers page of our site.
- Get your credentials
You need an
apiKey, anapiSecretand awebhookUrlregistered for your account. See Credentials. - Sign every request
Send
X-Api-Key,X-TimestampandX-Signatureon each call. See Authentication. - Create a pay-in
Call
POST /api/merchant/pay-infrom your server and show the payer the details it returns, or redirect them to the hosted payment page. When the payer reports a UTR, relay it withPOST /api/merchant/pay-in/{payInId}/utr. If the call times out, look the order up by yourmerchantOrderIdwithGET /api/merchant/pay-in/statusbefore you send it again. - Credit the order from the webhook
Verify the signature of
pay_in.completed(see Signature verification) and credit the order. PollGET /pay-in/{payInId}/statusas a fallback.
Environment and formats
| Property | Value |
|---|---|
| Merchant dashboard (web application) | https://portal.atlas-online.io |
| API base URL | Production: https://api.atlas-online.ioSandbox: https://sandbox.api.atlas-online.io |
| API prefix | /api/merchant |
| Protocol | HTTPS (TLS 1.2+) |
| Request format | application/json (UTF-8); multipart/form-data for dispute attachments |
| Response format | application/json (UTF-8) |
| Identifiers | UUID v4 (string, hyphenated) |
| Timestamps | ISO 8601, UTC (2026-04-14T15:30:00Z) |
| Monetary values | Decimal with 2 fractional digits (5000.00)Whole number of INR, no fractional part (5000). Fractional amounts are rejected, never rounded.Whole number of KRW, no fractional part (150000). Fractional amounts are rejected, never rounded. |
| Authentication | HMAC-SHA256 per-request signature (see Authentication) |
The endpoint map lists every endpoint, including balance, withdrawals and transfers.
Your market
Send this geo value on every request. All amounts in this reference are in the currency below.
Region (geo) | Enum value | Currency |
|---|---|---|
Argentina | 1 | ARS |
Venezuela | 2 | VES |
India | 3 | INR |
SouthKorea | 4 | KRW |
Market overview: Argentina. Method guide: CVU/CBU. Market overview: Venezuela. Method guide: Pago Móvil. Market overview: India. Method guides: UPI, IMPS. Market overview: South Korea. Method guides: Korean bank transfer, Kakao Pay.
What your market supports
| Feature | In your market |
|---|---|
| Payment methods | CVU or CBU transfers for pay-ins; pay-outs to a CBU.Pago Móvil for pay-ins and pay-outs.UPI, UPI Intent and IMPS for pay-ins; IMPS or UPI for pay-outs.Bank transfer and Kakao Pay for pay-ins; pay-outs by bank transfer. |
| Pay-out recipient | Argentina uses a single CBU number (recipientCbu).Venezuela requires recipientPhone + recipientCedula + recipientBank + recipientAccountNumber.India has two rails. IMPS (default) requires recipientAccountNumber + recipientBank + recipientIfsc. UPI (paymentMethod: "upi", new in v2.6) requires recipientUpiVpa alone.South Korea (kor_bank) requires recipientAccountNumber + recipientBank. See Create a pay-out for the full request schema. |
| Pay-in confirmation | Argentina pay-ins auto-confirm via IMAP parsing of bank notifications.Venezuela has no automation: Atlas confirms each pay-in by hand once the money arrives, so wait for the pay_in.completed webhook or poll the status endpoint.Atlas confirms an India pay-in once the money reaches the receiving account in the Atlas network: automatically from that account's payment notification where this is set up, otherwise by hand. Either way the pay-in carries requiresManualConfirmation: true, which says the payment is confirmed on the Atlas side; it does not mean a person checks every payment, and you have nothing to confirm. A UTR is not required. If the payer reports one, relay it to POST /api/merchant/pay-in/{id}/utr: it is stored on the pay-in as matching context, and the call also returns the current status without waiting for the webhook.South Korea has no automation: Atlas confirms each pay-in against the payer's bank or Kakao Pay receipt, so wait for the pay_in.completed webhook or poll the status endpoint. No transaction reference is required. |
| Hosted and embedded checkout | Available. See Hosted checkout and Embedded checkout. |
| Bank catalog | Available. See List banks. |
400 VALIDATION_ERROR.Send only the fields that belong to the geo and rail. Cross-geo and cross-rail fields are rejected with 400 VALIDATION_ERROR.
Choose an integration path
Every path starts with the same server call, POST /api/merchant/pay-in. They differ in who renders the payment screen.
| Path | Who renders the payment screen | Docs |
|---|---|---|
| Payment link (hosted checkout) | Atlas. You redirect the payer to checkout.url. | Hosted checkout · Payment link on our site |
| Embedded checkout | Atlas, inside a frame on your page. You mount checkout.embedUrl. | Embedded checkout · Embedded checkout on our site |
| H2H API | You. Your UI shows the payment details returned by the API. | Create a pay-in · H2H API on our site |
In your market you integrate over the H2H API.
Payment screen options compares what you can show the payer on your own screen.
Credentials
| Credential | Description |
|---|---|
apiKey | Public identifier, format mk_<48-hex>. |
apiSecret | 64-hex secret. Signs your requests and verifies our webhooks. Store it in a secure secret vault. |
webhookUrl | Publicly reachable HTTPS endpoint that accepts POST and returns HTTP 2xx. |
apiSecret and the old one stops working at that moment; there is no overlap window. Agree on the time and deploy the new secret right away. The apiKey does not change.
Versioning
Every API response carries the header X-Api-Version: 2026-11-04, the contract date. Read it to detect which contract version you are talking to. The changelog lists what changed in each version and what you need to migrate.
Support
For questions about your account, credentials or a specific order, contact your Atlas account manager. Not a merchant yet? Contact us.
# PayIn POST /api/merchant/pay-in GET /api/merchant/pay-in/{id}/status POST /api/merchant/pay-in/{id}/utr # PayOut POST /api/merchant/pay-out GET /api/merchant/pay-out/{id}/status GET /api/merchant/banks?geo={geo} GET /api/merchant/payment-methods?geo={geo} # Disputes POST /api/merchant/disputes GET /api/merchant/disputes/{id} POST /api/merchant/disputes/{id}/messages GET /api/merchant/disputes/{id}/messages
Authentication
Every request to /api/merchant/* must include three request-signing headers. A request with a missing header, an invalid signature or a timestamp outside the window is rejected with 401.
To sign, you need your apiKey and apiSecret (see Credentials). The same apiSecret verifies the signatures on our webhooks.
Required headers
| Header | Description |
|---|---|
X-Api-Key | Merchant public identifier. |
X-Timestamp | Unix timestamp in seconds (UTC) at request time. Must be within ±300 seconds of server time. |
X-Signature | Lowercase hexadecimal HMAC-SHA256 signature of the canonical string (see below). |
Content-Type | application/json for JSON bodies; multipart/form-data for file uploads. |
Sign a request
The canonical string is the timestamp, a literal dot, then the raw request body. For GET requests the body is empty. For multipart/form-data requests it is the whole multipart body as sent, boundaries and file bytes included (see Sign a multipart request).
Take the timestamp
Use the current Unix time in seconds. It goes into
X-Timestampand into the canonical string.Serialize the body once
Produce the exact bytes you are going to send: the JSON text, or the encoded multipart body. For
GETrequests use an empty string.Build the canonical string
Join the timestamp, a literal
.and the body:timestamp + "." + raw_request_body.Compute the signature
Run HMAC-SHA256 over the canonical string with your
apiSecretas the key and hex-encode the result in lowercase.Send the headers
Put the result in
X-Signatureand send it withX-Api-KeyandX-Timestamp.
Sign a multipart request
The server computes the HMAC over the bytes of timestamp + "." followed by the multipart body exactly as received, file bytes included. Nothing is decoded as text, so an image or a PDF is signed as it is.
- Encode the form yourself before signing, so the boundary is already fixed. A client that builds the form while sending (
fetchwith aFormDatabody,curl -F) chooses the boundary after you have signed. - Feed the HMAC the bytes
timestamp + ".", then the encoded body bytes:hmac.update(`${timestamp}.`).update(body)in Node.js,hmac.new(secret, f"{timestamp}.".encode() + body, sha256)in Python. - Send those same bytes with the
Content-Type: multipart/form-data; boundary=…header that matches them.
The Node.js client in Disputes examples does exactly this.
Verification rules applied by the server
- All three headers are present and non-empty.
X-Timestampis a valid integer and within ±300 seconds of server clock.X-Api-Keyresolves to an active merchant account.- Computed HMAC-SHA256 matches
X-Signature(constant-time comparison).
Authentication errors
Every failure returns 401 with one of these codes in error.
| HTTP | Error code | Condition |
|---|---|---|
| 401 | MISSING_API_KEY | X-Api-Key header absent. |
| 401 | MISSING_SIGNATURE | X-Signature header absent. |
| 401 | MISSING_TIMESTAMP | X-Timestamp header absent. |
| 401 | INVALID_TIMESTAMP | X-Timestamp is not a valid integer. |
| 401 | EXPIRED_REQUEST | Timestamp outside ±300 s window. |
| 401 | INVALID_API_KEY | Unknown or inactive API key. |
| 401 | INVALID_SIGNATURE | Signature verification failed. |
Common mistakes
| Mistake | What you get |
|---|---|
| Signing one serialization of the body and sending another (pretty-printed, keys reordered, re-encoded by an HTTP client). | INVALID_SIGNATURE |
Signing a GET with a body such as {} instead of an empty string. | INVALID_SIGNATURE |
| Signing a multipart request over an empty body, or letting the HTTP client choose the boundary after signing. | INVALID_SIGNATURE |
| Leaving out the dot between the timestamp and the body. | INVALID_SIGNATURE |
Using the apiKey as the HMAC key instead of the apiSecret. | INVALID_SIGNATURE |
| Sending the timestamp in milliseconds. | EXPIRED_REQUEST |
| A server clock that has drifted by more than 300 seconds. | EXPIRED_REQUEST |
Clock skew
Synchronize the merchant server clock via NTP. The server accepts X-Timestamp up to 300 seconds behind or ahead of its own clock. Outside that window every request fails with EXPIRED_REQUEST.
Operational requirements
- The
apiSecretmust not appear in application logs, error reporters or version control. - Use HTTPS for every outbound call.
- To replace a secret, see Rotation. The old secret stops working as soon as the new one is issued, so deploy the new one right away.
// JSON request canonical = timestamp + "." + raw_request_body // GET: body is empty canonical = "1776172416." // multipart: the encoded body bytes as sent canonical = bytes(timestamp + ".") + multipart_body_bytes
const crypto = require('crypto'); function sign(timestamp, body) { return crypto .createHmac('sha256', API_SECRET) .update(`${timestamp}.${body}`, 'utf8') .digest('hex'); } // X-Signature: lowercase_hex(signature)
import hashlib import hmac def sign(timestamp: str, body: str) -> str: message = f"{timestamp}.{body}".encode("utf-8") return hmac.new(API_SECRET.encode("utf-8"), message, hashlib.sha256).hexdigest()
TIMESTAMP=$(date +%s) BODY='' # GET: empty body SIGNATURE=$(printf '%s.%s' "$TIMESTAMP" "$BODY" \ | openssl dgst -sha256 -hmac "$ATLAS_API_SECRET" | sed 's/^.*= //') curl https://api.atlas-online.io/api/merchant/balance \ -H "X-Api-Key: $ATLAS_API_KEY" \ -H "X-Timestamp: $TIMESTAMP" \ -H "X-Signature: $SIGNATURE"
X-Api-Key: mk_a838ef5b55d93add046ec9ea426420fef3dca5557d58ebd1 X-Timestamp: 1776172416 X-Signature: 9f2a7e4b5c8d1a3e0f6b2d9e7c4a5b8f1d2e3c4a5b6d7e8f9a0b1c2d3e4f5a6b Content-Type: application/json
Create a pay-in
/api/merchant/pay-inA pay-in is an incoming payment from your customer. This call reserves a destination (a CBU/CVU account) (phone, cédula and bank, paid over Pago Móvil) (a UPI VPA and QR with a deep link, or an IMPS account with its IFSC) (a bank account or a Kakao Pay QR) and returns the details you show the payer.
When to use
Call it from your server when a customer checks out. On a host-to-host integration you draw the payment screen yourself from the response; see Show the details to the payer below. If you would rather not build a screen, add checkout to the same request and redirect the payer to the hosted checkout, or mount the embedded checkout on your page.
Every request is signed with HMAC-SHA256 (X-Api-Key, X-Timestamp, X-Signature). See Authentication.
Request
Path parameters
None.
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
amount | decimal | Required | Amount to collect, greater than zero. Send it with two decimal places. Whole INR only: a fractional amount is rejected with VALIDATION_ERROR, never rounded. Whole KRW only: a fractional amount is rejected with VALIDATION_ERROR, never rounded. |
currency | string | Required | ISO 4217 code (ARS) (VES) (INR) (KRW). |
geo | integer | string | Required | Region code or enum name. Must match a region enabled for your merchant account. |
expiresIn | integer | Required | Lifetime of the pay-in, in seconds. |
merchantOrderId | string | Required | Your order ID and the idempotency key. Repeating a request for an existing order that has not failed returns the original pay-in. After a timeout you can look the pay-in up by this id with Get pay-in status by merchantOrderId. |
clientReference | string | Required | Your stable identifier for the end customer (the payer). Pattern [A-Za-z0-9._:-], up to 64 characters. Use it for deduplication and reconciliation on your side. |
paymentMethod | string | Optional | Payment method code for this market, from the same vocabulary as the pay-out recipientBank. When you omit it, the platform uses the market's default rail. List the available code values with GET /api/merchant/payment-methods?geo={geo}. A code that is unknown or not enabled for your account returns METHOD_NOT_CONTRACTED. |
paymentMethod | string | Optional | Payment method code for this market (for example 0102 = Banco de Venezuela, 0108 = Provincial), from the same vocabulary as the pay-out recipientBank. When you omit it, the platform uses the market's default rail: any bank is Pago Móvil. List the available code values with GET /api/merchant/payment-methods?geo={geo}. A code that is unknown or not enabled for your account returns METHOD_NOT_CONTRACTED. |
paymentMethod | string | Required | Payment method code for this market: upi, upi_intent (new in v2.6) or imps. Several rails share this market and the rail decides which receiving account serves the order, so a missing value returns 400 VALIDATION_ERROR. upi and upi_intent are one rail with the same behaviour, UTR flow, spreads, webhooks and receiving accounts. Pick upi_intent when you want to hand the payer the upiIntentLink deep link rather than a QR. List the available code values with GET /api/merchant/payment-methods?geo={geo}. A code that is unknown or not enabled for your account returns METHOD_NOT_CONTRACTED. |
paymentMethod | string | Required | Payment method code for this market: kor_bank or kakao_pay. Several rails share this market and the rail decides which receiving account serves the order, so a missing value returns 400 VALIDATION_ERROR. List the available code values with GET /api/merchant/payment-methods?geo={geo}. A code that is unknown or not enabled for your account returns METHOD_NOT_CONTRACTED. |
checkout | object | Optional | New in v2.5. Asks for an Atlas-hosted payment page; the full field reference is in Hosted checkout. Accepted for paymentMethod: "upi", "upi_intent" or "imps" (widened in v2.6) served in the Atlas network rather than by an external provider. Anything else returns 400 CHECKOUT_NOT_SUPPORTED and no pay-in is created. With checkout, expiresIn must be within 300–3600 seconds. |
includeUpiAppLinks | boolean | Optional | New in v2.14. Default false. When true, a UPI order (upi or upi_intent) also returns upiAppLinks: deep links that open the PhonePe and Paytm send-money screens directly. On orders where it does not apply (imps, external routing) the flag is ignored, never an error. See Payment screen options. |
checkout | object | Optional | New in v2.5, available in this market since v2.8. Asks for an Atlas-hosted payment page; the full field reference is in Hosted checkout. Accepted for paymentMethod: "kor_bank" or "kakao_pay" served in the Atlas network rather than by an external provider. Anything else returns 400 CHECKOUT_NOT_SUPPORTED and no pay-in is created. With checkout, expiresIn must be within 300–3600 seconds. |
The body has the same shape in every market. The platform picks an available receiving account and returns the payment details that apply to this market.
Response
Response fields · 201 Created
null or "". The common fields (payInId … requiresManualConfirmation, plus requestedAmount, amountAdjustment and, since v2.15, exchangeRate, commissionRate and amountUsdt) are always present. qrImageBase64 and qrPayload appear only when the payment details include a QR. Read keys by name and treat a missing key as "not applicable".| Field | Type | Description |
|---|---|---|
payInId | UUID | Platform identifier. Use it for status queries and every later call. |
displayId | string | Human-readable identifier (PI00000001). |
geo | string | Region the pay-in was issued for. |
ownerName | string | Account holder name to show the payer. |
bankName | string | Destination bank or payment provider. |
cbu | string | Destination CBU/CVU. |
cbu | string | Present only on orders served by an external provider, when the provider issues a destination account number (número de cuenta) alongside the Pago Móvil details. Absent otherwise. |
recipientPhone | string | Destination phone for the Pago Móvil transfer. |
recipientCedula | string | Destination ID (cédula). |
upiVpa | string | Destination UPI VPA (for example payer@axl), the text fallback next to the QR. Present only on upi and upi_intent orders. |
accountNumber | string | Destination bank account. Present only on imps orders. |
ifscCode | string | Destination IFSC branch code. Present only on imps orders. |
upiIntentLink | string | New in v2.6. UPI deep link: upi://pay?pa=<vpa>&pn=<payee>&am=<amount, 2dp>&cu=INR. Since v2.10 the link carries only the payee, the amount and the currency; the tn and tr parameters were removed. Present on every pay-in on the UPI rail (upi and upi_intent), idempotent replays included. The key is omitted entirely for imps, for other regions and for cascade-routed orders. Render it as a tap target on Android and keep the QR as the iOS and desktop fallback. Not included in the webhooks or in GET /pay-in/{payInId}/status. |
accountNumber | string | Destination bank account. Present only on kor_bank orders. |
amount | decimal | What the payer has to transfer. Show this value to the payer. |
requestedAmount | decimal | New in v2.11. What you asked for, and what you are credited for. Equal to amount unless the payable amount was moved. Always present. |
amountAdjustment | decimal | New in v2.11. amount minus requestedAmount; 0 when the amount was not moved. Always present. Stays within ± the range configured for your account (for example -2 … 2 in whole rupees, or -1.5 … 1.5 in steps of 0.5). It can be negative and may be fractional. |
currency | string | Currency code. |
expiresAt | datetime | Expiration timestamp. |
requiresManualConfirmation | boolean | true when Atlas confirms the payment by hand. Argentina pay-ins are confirmed automatically from bank notifications.Always true in Venezuela: Atlas confirms each pay-in by hand.Always true in India. It says the payment is confirmed on the Atlas side once it reaches the receiving account, automatically from that account's payment notification where this is set up, otherwise by hand. It does not mean a person checks every payment, and you have nothing to confirm: wait for pay_in.completed.Always true in South Korea: Atlas confirms each pay-in against the payer's bank or Kakao Pay receipt. |
qrImageBase64 | string | QR image as a data URI (data:image/png;base64,...). Present only when the payment details include a QR. |
qrPayload | string | Raw host-to-host QR payload (EMV / upi://pay) for rendering the QR yourself. Present only when the payment details include a QR. |
upiAppLinks | object | New in v2.14. Present only when the request sent includeUpiAppLinks: true and the order has upiIntentLink. Omitted otherwise, so a request without the flag gets a byte-identical response. Members: phonepe (phonepe://native?data=…&id=p2ppayment) and paytm (paytmmp://cash_wallet?pa=…&am=…&cu=INR&featuretype=money_transfer). Both carry the payable amount, locked. Treat them as opaque strings and use them unchanged as a button href. Placed right before checkout. Not included in the webhooks or in GET /pay-in/{payInId}/status. See Payment screen options. |
checkout | object | New in v2.5. Present only when the request asked for checkout and it was accepted. The key is omitted entirely otherwise, so requests without checkout get a response byte-identical to v2.4. Fields: url, expiresAt, locale. See Hosted checkout. |
exchangeRate | decimal | New in v2.15. Your exchange rate for this order, fixed at creation: units of currency per 1 USDT. requestedAmount / exchangeRate is the gross USDT value of the order. Always present. |
commissionRate | decimal | New in v2.15. Your commission on this order, in percent (8.5 means 8.5%). Always present. |
amountUsdt | decimal | New in v2.15. The USDT credited to your balance when the order completes: requestedAmount / exchangeRate × (1 − commissionRate / 100), rounded to 6 decimal places. The same value as amountUsdt in the pay_in.completed webhook. Always present. Treat amountUsdt as authoritative: recomputing it from the published exchangeRate can differ by 0.000001, because the gross is rounded to 6 decimal places before the commission is applied. |
Amount adjustment (v2.11; range per account since 2026-09-29). A requisite holds only one open order per exact amount, and round amounts are common. When amount adjustment is enabled for your account, a UPI order (upi or upi_intent) whose exact amount is already open on every requisite is served at the nearest free amount within ± the range configured for your account, in its step, up or down. The exact amount is always tried first.
With ±2 in whole rupees, a request of 1000 may come back as 1001, 999, 1002 or 998. With ±1.5 in steps of 0.5 it may come back as 1000.5, 999.5, 1001, 999, 1001.5 or 998.5. When adjustment is not enabled (the default), the order gets the exact amount or NO_CARD_AVAILABLE. An imps order is never moved, because its payer types the amount into a bank app by hand.
Show amount to the payer exactly as returned; it may carry paise. You are credited for requestedAmount, and amountAdjustment (within ± your range, possibly fractional) tells you which way the amount moved. The direction alternates so the adjustments cancel out over time, and your settlement is unchanged either way. Your account manager sets the range and the step.
Show the details to the payer
Read the keys of the 201 response by name and render the ones present. Always show amount exactly as returned; the order is open until expiresAt.
| Method | Keys to show | What the payer does |
|---|---|---|
| CVU/CBU transfer | cbu, ownerName, bankName, amount; qrImageBase64 or qrPayload when present | Sends a bank transfer to the CBU/CVU, or scans the QR. |
| Method | Keys to show | What the payer does |
|---|---|---|
| Pago Móvil | recipientPhone, recipientCedula, bankName, ownerName, amount | Sends a Pago Móvil payment to the phone, cédula and bank. |
| Pago Móvil, external provider | As above, plus cbu when present | The provider issued a destination account number (número de cuenta) alongside the Pago Móvil details. |
| Method | Keys to show | What the payer does |
|---|---|---|
UPI (upi, upi_intent) | qrImageBase64 or qrPayload, upiVpa, upiIntentLink, upiAppLinks when requested, amount | Scans the QR, taps a deep link, or types the VPA into a UPI app. |
IMPS (imps) | accountNumber, ifscCode, bankName, ownerName, amount | Sends an IMPS transfer from a bank app, typing the amount by hand. |
Payment screen options compares the four ways to present a UPI order and gives a layout that works.
| Method | Keys to show | What the payer does |
|---|---|---|
Bank transfer (kor_bank) | accountNumber, bankName, ownerName, amount | Sends a bank transfer to the account. |
Kakao Pay (kakao_pay) | qrImageBase64 or qrPayload, ownerName, amount | Scans the Kakao Pay QR. |
Errors
| HTTP status | Error code | Message |
|---|---|---|
| 400 | VALIDATION_ERROR | Validation failed (body, currency, geo, merchant status, exchange rate). |
| 400 | NO_CARD_AVAILABLE | No processing capacity is available right now. Retry after a short delay. |
| 400 | AMOUNT_BELOW_MINIMUM | The amount is below your configured minimum. |
| 400 | AMOUNT_ABOVE_MAXIMUM | The amount exceeds your configured maximum. |
| 400 | METHOD_NOT_CONTRACTED | The requested paymentMethod is not enabled for your account. |
| 400 | CLIENT_BLOCKED | New in v2.18. Atlas has blocked new pay-ins from this clientReference on your account. The pay-in is not created and no webhook follows. Do not retry: the answer stays the same until Atlas lifts the block. Show the payer a neutral message, for example “Payment is unavailable. Please contact the shop's support.” See Blocked clients. |
| 400 | CHECKOUT_NOT_SUPPORTED | New in v2.5, scope widened in v2.6. checkout was requested outside the supported scope: not paymentMethod: "upi", "upi_intent" or "imps" served in the Atlas network, or expiresIn outside 300–3600s. The pay-in is not created. |
| 400 | CHECKOUT_NOT_SUPPORTED | New in v2.5, available in this market since v2.8. checkout was requested outside the supported scope: not paymentMethod: "kor_bank" or "kakao_pay" served in the Atlas network, or expiresIn outside 300–3600s. The pay-in is not created. |
| 401 | See Authentication | Authentication failed. |
Every error code with its meaning is listed in the error reference.
Notes
Flow. Show the returned CBU/CVU details to the payer and credit the order from the pay_in.completed webhook.
Flow. Show the returned Pago Móvil details to the payer and credit the order from the pay_in.completed webhook.
Flow. Show the returned UPI QR and VPA (or the IMPS account and IFSC) to the payer. After they pay, wait for the pay_in.completed webhook. If the payer reports a UTR (transaction reference), relay it with POST /pay-in/{payInId}/utr: it helps match the payment but is not required to confirm it. See Submit UTR.
Flow. Show the returned bank account and bank (kor_bank) or Kakao Pay QR (kakao_pay) to the payer and wait for the pay_in.completed webhook: Atlas confirms the payment against the payer's receipt. You do not have to relay a transaction reference.
UPI rail: upi, upi_intent and upiIntentLink (v2.6). India exposes three methods over two rails. upi and upi_intent both ride the upi rail, with identical payment details (a VPA) served by the same receiving accounts; imps rides its own. upi_intent is the intent-oriented variant: routing, spreads, the UTR confirmation flow and every webhook match upi, and only the way you hand the payer the details differs.
Every pay-in on the UPI rail, plain upi included, returns upiIntentLink in the create response (idempotent replays included). On Android, render it as a tap target ("Pay in UPI app"): the payer's UPI app opens pre-filled with the VPA, payee name and exact amount, so nothing can be mistyped. The link carries no order reference: since v2.10 it is upi://pay?pa=…&pn=…&am=…&cu=INR only, so read displayId from the response instead of parsing the link. Keep the QR (qrImageBase64 / upiVpa) as the fallback for iOS and desktop. Confirmation works exactly as for upi.
The field is absent for imps, for other regions and for cascade-routed orders, and it is not part of the webhooks or of GET /pay-in/{payInId}/status. Since v2.14 you can also ask for PhonePe and Paytm deep links with includeUpiAppLinks: true; Payment screen options compares every way to present a UPI order.
Blocked clients
New in v2.18. Atlas can block new pay-ins from one of your clients. The block applies to your account and to that exact clientReference; the same reference on another merchant's account is not affected. While it is active, a create request for a new merchantOrderId with that clientReference is refused with 400 CLIENT_BLOCKED and the message "Pay-ins from this clientReference are blocked. Contact Atlas support." The refusal happens before any payment details are assigned, so nothing is reserved and no webhook is sent.
- A repeat of a
merchantOrderIdthat already exists still returns that pay-in, as before. Creating a new order for the client, including one that replaces a failed order, is refused. - Do not retry and do not switch to another
clientReferencefor the same payer:clientReferencemust stay stable per end customer. Contact Atlas support if you think the block is a mistake. Once Atlas lifts it, new pay-ins for the client are accepted again. - The API does not tell you why a client is blocked and does not list blocked clients. Pay-outs to the same client are not affected.
See also
- Get pay-in status and the status reference for what happens after creation.
- Get pay-in status by merchantOrderId if the create timed out and you have no
payInId. - Cancel a pay-in you no longer need, for example after another provider served the order.
- Webhook events and payloads for
pay_in.completed. - Hosted checkout and embedded checkout if you do not want to build the payment screen.
- H2H API overview on atlas-online.io.
- CVU/CBU and Argentina on atlas-online.io.
- Pago Móvil and Venezuela on atlas-online.io.
- UPI, IMPS and India on atlas-online.io.
- Korean bank transfer, Kakao Pay and South Korea on atlas-online.io.
{
"amount": 5000.00,
"currency": "ARS",
"geo": 1,
"expiresIn": 900,
"merchantOrderId": "order-12345",
"clientReference": "customer-7781",
"paymentMethod": "cvu"
}{
"amount": 1500.00,
"currency": "VES",
"geo": 2,
"expiresIn": 900,
"merchantOrderId": "order-ve-12345",
"clientReference": "customer-7781",
"paymentMethod": "0102"
}{
"amount": 5000,
"currency": "INR",
"geo": 3,
"expiresIn": 900,
"merchantOrderId": "order-in-12345",
"clientReference": "customer-7781",
"paymentMethod": "upi"
}{
"amount": 3049,
"currency": "INR",
"geo": 3,
"expiresIn": 900,
"merchantOrderId": "order-in-intent-12345",
"clientReference": "customer-7781",
"paymentMethod": "upi_intent"
}{
"amount": 5000,
"currency": "INR",
"geo": 3,
"expiresIn": 900,
"merchantOrderId": "order-in-imps-12345",
"clientReference": "customer-7781",
"paymentMethod": "imps"
}{
"amount": 50000,
"currency": "KRW",
"geo": 4,
"expiresIn": 900,
"merchantOrderId": "order-kr-12345",
"clientReference": "customer-7781",
"paymentMethod": "kor_bank"
}{
"amount": 50000,
"currency": "KRW",
"geo": 4,
"expiresIn": 900,
"merchantOrderId": "order-kr-kakao-12345",
"clientReference": "customer-7781",
"paymentMethod": "kakao_pay"
}{
"payInId": "1c75c312-93f3-4b34-903e-10fb38a56bec",
"displayId": "PI00000007",
"geo": "Argentina",
"ownerName": "Francisco Ezequies",
"bankName": "Prex",
"cbu": "0000177501057000189625",
"amount": 5000.00,
"requestedAmount": 5000.00,
"amountAdjustment": 0,
"currency": "ARS",
"expiresAt": "2026-04-14T13:28:36.837Z",
"requiresManualConfirmation": false,
"qrImageBase64": "data:image/png;base64,iVBORw0KGgo...",
"exchangeRate": 1000,
"commissionRate": 5,
"amountUsdt": 4.75
}{
"payInId": "8b2d1f4e-7c9a-4123-a5d6-9e8f0b1c2d3e",
"displayId": "PI00000023",
"geo": "Venezuela",
"ownerName": "Carlos Rodríguez",
"bankName": "Banesco",
"recipientPhone": "+584121234567",
"recipientCedula": "V-12345678",
"amount": 1500.00,
"requestedAmount": 1500.00,
"amountAdjustment": 0,
"currency": "VES",
"expiresAt": "2026-05-04T15:00:00.000Z",
"requiresManualConfirmation": true,
"exchangeRate": 1000,
"commissionRate": 2,
"amountUsdt": 1.47
}{
"payInId": "3f1a9c2b-5d7e-4f80-b1c2-d3e4f5a6b7c8",
"displayId": "PI00000031",
"geo": "India",
"ownerName": "Ravi Kumar",
"bankName": "HDFC Bank",
"upiVpa": "payer@axl",
"upiIntentLink": "upi://pay?pa=payer%40axl&pn=Ravi%20Kumar&am=4999.00&cu=INR",
"amount": 4999,
"requestedAmount": 5000,
"amountAdjustment": -1,
"currency": "INR",
"expiresAt": "2026-07-31T15:00:00.000Z",
"requiresManualConfirmation": true,
"qrImageBase64": "data:image/png;base64,iVBORw0KGgo...",
"qrPayload": "upi://pay?pa=payer@axl",
"exchangeRate": 105.5,
"commissionRate": 8.5,
"amountUsdt": 43.364929
}{
"payInId": "6b7c8d9e-0f12-4a34-b5c6-d7e8f9a0b1c2",
"displayId": "PI00000991",
"geo": "India",
"ownerName": "Ravi Sharma",
"bankName": "Yes Bank",
"upiVpa": "ravi.sharma@ybl",
"upiIntentLink": "upi://pay?pa=ravi.sharma%40ybl&pn=Ravi%20Sharma&am=3049.00&cu=INR",
"amount": 3049,
"requestedAmount": 3049,
"amountAdjustment": 0,
"currency": "INR",
"expiresAt": "2026-09-09T09:15:00.000Z",
"requiresManualConfirmation": true,
"qrImageBase64": "data:image/png;base64,iVBORw0KGgo...",
"exchangeRate": 105.5,
"commissionRate": 8.5,
"amountUsdt": 26.443934
}{
"payInId": "7c8d9e0f-1234-4b56-c7d8-e9f0a1b2c3d4",
"displayId": "PI00000992",
"geo": "India",
"ownerName": "Ravi Kumar",
"bankName": "HDFC Bank",
"accountNumber": "40145477128",
"ifscCode": "HDFC0001234",
"amount": 5000,
"requestedAmount": 5000,
"amountAdjustment": 0,
"currency": "INR",
"expiresAt": "2026-09-09T09:15:00.000Z",
"requiresManualConfirmation": true,
"exchangeRate": 105.5,
"commissionRate": 8.5,
"amountUsdt": 43.364929
}{
"payInId": "4a2b8d3c-6e8f-4a91-c2d3-e4f5a6b7c8d9",
"displayId": "PI00000032",
"geo": "SouthKorea",
"ownerName": "Kim Minseo",
"bankName": "KB Kookmin Bank",
"accountNumber": "110123456789",
"amount": 50000,
"requestedAmount": 50000,
"amountAdjustment": 0,
"currency": "KRW",
"expiresAt": "2026-08-07T15:00:00.000Z",
"requiresManualConfirmation": true,
"exchangeRate": 1390,
"commissionRate": 4,
"amountUsdt": 34.532374
}{
"payInId": "5c3d9e4f-7a9b-4b02-d3e4-f5a6b7c8d9e0",
"displayId": "PI00000033",
"geo": "SouthKorea",
"ownerName": "Kim Minseo",
"bankName": "Kakao Pay",
"amount": 50000,
"requestedAmount": 50000,
"amountAdjustment": 0,
"currency": "KRW",
"expiresAt": "2026-08-07T15:00:00.000Z",
"requiresManualConfirmation": true,
"qrImageBase64": "data:image/png;base64,iVBORw0KGgo...",
"qrPayload": "https://qr.kakaopay.com/AbC9fQ2",
"exchangeRate": 1390,
"commissionRate": 4,
"amountUsdt": 34.532374
}{
"error": "NO_CARD_AVAILABLE",
"message": "No payment card is available to process this request at the moment"
}{
"error": "INVALID_SIGNATURE",
"message": "Signature verification failed"
}Get pay-in status
/api/merchant/pay-in/{payInId}/statusReturns the current state of a pay-in, with its amounts and your USDT credit.
When to use
Call it when you need an order's state on demand, for example to reconcile or when a webhook has not reached you yet. The webhooks report the same transitions as they happen.
No payInId because the create request timed out? Look the pay-in up by your own id with Get pay-in status by merchantOrderId; it returns the same body.
Sign the request with HMAC-SHA256 like every other call. A GET has an empty body, so the signature canonical string is "{timestamp}.". See Authentication.
Request
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
payInId | UUID | Required | Identifier returned by Create a pay-in. |
Response
Response fields · 200 OK
| Field | Type | Description |
|---|---|---|
payInId | UUID | Platform identifier of the pay-in. |
displayId | string | Human-readable identifier. |
status | enum | WaitingPayment, Completed, Expired, Failed. See Statuses. |
amount | decimal | What the payer has to transfer. |
requestedAmount | decimal | New in v2.11. What you asked for, and what you are credited for. Equal to amount unless the payable amount was moved. |
amountAdjustment | decimal | New in v2.11. amount minus requestedAmount; 0 when the amount was not moved. Stays within ± the range configured for your account (for example -2 … 2 in whole rupees, or -1.5 … 1.5 in steps of 0.5). It can be negative and may be fractional. |
originalAmount | decimal | New in v2.12. What you originally asked for. Equal to requestedAmount unless support closed the order on a lower amount the payer actually sent. In that case amount and requestedAmount hold the received amount (what you are credited for), and this field keeps your original ask. |
currency | string | Currency code. |
createdAt | datetime | Creation timestamp. |
expiresAt | datetime | Expiration timestamp. |
completedAt | datetime | null | Set when status = Completed. |
expiredAt | datetime | null | Set when status = Expired. |
payerName | string | null | Reported payer name, filled in after Completed. |
geo | string | Region the pay-in belongs to. |
isManualConfirmation | boolean | The same value as requiresManualConfirmation: true when the payment is confirmed on the Atlas side rather than by automation. A pay-in completed after it expired is always true. |
requiresManualConfirmation | boolean | true when the payment is confirmed on the Atlas side rather than by automation. It asks nothing of you; see the field on Create a pay-in. |
utr | string | null | Unique Transaction Reference, when set. See Submit UTR. |
qrPayload | string | null | Raw host-to-host QR payload (EMV / upi://pay), or null. |
clientReference | string | null | Echo of the clientReference sent on creation. |
exchangeRate | decimal | New in v2.15. Your exchange rate for this order, fixed at creation: units of currency per 1 USDT. The same value the create response returned. |
commissionRate | decimal | New in v2.15. Your commission on this order, in percent (8.5 means 8.5%). |
amountUsdt | decimal | New in v2.15. The USDT credited to your balance when the order completes: requestedAmount / exchangeRate × (1 − commissionRate / 100), rounded to 6 decimal places. The same value as amountUsdt in the pay_in.completed webhook. Treat amountUsdt as authoritative: recomputing it from the published exchangeRate can differ by 0.000001, because the gross is rounded to 6 decimal places before the commission is applied. Present in every status. On an order support closed on a lower received amount, it is priced on that amount. |
Statuses
| Status | Numeric | Description |
|---|---|---|
| WaitingPayment | 1 | Initial state. The destination is issued and the transfer is awaited. |
| Completed | 2 | Payment detected and reconciled. Triggers pay_in.completed. |
| Expired | 3 | The lifetime elapsed without payment. Triggers pay_in.expired. |
| Failed | 4 | Terminal failure: cancelled by Atlas (triggers pay_in.failed), or by you with Cancel a pay-in (no webhook). |
Permitted transitions
WaitingPayment → Completed WaitingPayment → Expired Expired → Completed // isManualConfirmation = true WaitingPayment → Failed // cancelled by Atlas or by you Expired → Failed // cancelled by you
An Expired order can still move to Completed through manual confirmation, so keep handling pay_in.completed after pay_in.expired. A pay-in you cancel with Cancel a pay-in moves to Failed from WaitingPayment or Expired and can no longer complete. See also the status reference.
Errors
| HTTP status | Error code | Message |
|---|---|---|
| 404 | NOT_FOUND | "PayIn not found": the pay-in does not exist or does not belong to your account. |
| 401 | See Authentication | Authentication failed. |
Every error code is listed in the error reference.
See also
- Get pay-in status by merchantOrderId when you have no
payInId. - Cancel a pay-in you no longer need.
- Webhook events and payloads for
pay_in.completedandpay_in.expired. - Submit UTRSubmit a payer reference to attach the payer's transfer reference to an open order.
- H2H API overview on atlas-online.io.
{
"payInId": "1c75c312-93f3-4b34-903e-10fb38a56bec",
"displayId": "PI00000007",
"status": "Completed",
"amount": 5000.00,
"requestedAmount": 5000.00,
"amountAdjustment": 0,
"originalAmount": 5000.00,
"currency": "ARS",
"createdAt": "2026-04-14T13:13:36.837Z",
"expiresAt": "2026-04-14T13:28:36.837Z",
"completedAt": "2026-04-14T13:13:50.651Z",
"expiredAt": null,
"payerName": "Juan Martinez",
"isManualConfirmation": false,
"geo": "Argentina",
"requiresManualConfirmation": false,
"qrPayload": null,
"clientReference": "customer-7781",
"exchangeRate": 1000,
"commissionRate": 5,
"amountUsdt": 4.75
}{
"payInId": "8b2d1f4e-7c9a-4123-a5d6-9e8f0b1c2d3e",
"displayId": "PI00000023",
"status": "Completed",
"amount": 1500.00,
"requestedAmount": 1500.00,
"amountAdjustment": 0,
"originalAmount": 1500.00,
"currency": "VES",
"createdAt": "2026-05-04T14:45:00.000Z",
"expiresAt": "2026-05-04T15:00:00.000Z",
"completedAt": "2026-05-04T14:52:11.204Z",
"expiredAt": null,
"payerName": "Luis Pérez",
"isManualConfirmation": true,
"geo": "Venezuela",
"requiresManualConfirmation": true,
"qrPayload": null,
"clientReference": "customer-7781",
"exchangeRate": 1000,
"commissionRate": 2,
"amountUsdt": 1.47
}{
"payInId": "3f1a9c2b-5d7e-4f80-b1c2-d3e4f5a6b7c8",
"displayId": "PI00000031",
"status": "Completed",
"amount": 4999,
"requestedAmount": 5000,
"amountAdjustment": -1,
"originalAmount": 5000,
"currency": "INR",
"createdAt": "2026-07-31T14:45:00.000Z",
"expiresAt": "2026-07-31T15:00:00.000Z",
"completedAt": "2026-07-31T14:51:07.118Z",
"expiredAt": null,
"payerName": "Amit Verma",
"isManualConfirmation": true,
"geo": "India",
"requiresManualConfirmation": true,
"utr": "0086543219",
"qrPayload": "upi://pay?pa=payer@axl",
"clientReference": "customer-7781",
"exchangeRate": 105.5,
"commissionRate": 8.5,
"amountUsdt": 43.364929
}{
"payInId": "4a2b8d3c-6e8f-4a91-c2d3-e4f5a6b7c8d9",
"displayId": "PI00000032",
"status": "Completed",
"amount": 50000,
"requestedAmount": 50000,
"amountAdjustment": 0,
"originalAmount": 50000,
"currency": "KRW",
"createdAt": "2026-08-07T14:45:00.000Z",
"expiresAt": "2026-08-07T15:00:00.000Z",
"completedAt": "2026-08-07T14:49:33.902Z",
"expiredAt": null,
"payerName": "Park Jisoo",
"isManualConfirmation": true,
"geo": "SouthKorea",
"requiresManualConfirmation": true,
"qrPayload": null,
"clientReference": "customer-7781",
"exchangeRate": 1390,
"commissionRate": 4,
"amountUsdt": 34.532374
}{
"error": "NOT_FOUND",
"message": "PayIn not found"
}Get pay-in status by merchantOrderId
/api/merchant/pay-in/status?merchantOrderId={merchantOrderId}Finds a pay-in by your own merchantOrderId and returns its status. The response is the same as Get pay-in status, payInId included.
New in v2.19. Use it when you do not have the payInId, typically because the create request never returned a response. The same lookup exists for pay-outs: Get pay-out status by merchantOrderId.
When to use
Your POST /api/merchant/pay-in timed out or the connection dropped, so you have no payInId and do not know whether the pay-in was created. Ask for the status by the merchantOrderId you sent:
Look the order up
Call this endpoint with the
merchantOrderIdof the create that got no response.200: the pay-in exists
Atlas received the create. Use the returned
statusandpayInId, and do not create the pay-in again under a newmerchantOrderId.404: the pay-in was not created
Repeat
POST /api/merchant/pay-inwith the samemerchantOrderIdand the same body. Create is idempotent bymerchantOrderId: if the first request reached Atlas after your lookup, the repeat returns that pay-in with 201 and does not create a second one. If the repeat itself fails (a timeout, a 5xx, or a 400 you did not get before), look the order up again before the next attempt: the first request may have been completed in the meantime.
The status body does not carry the payment details you show the payer. If the pay-in exists and you still need them, repeat the create with the same merchantOrderId and the same body: for a pay-in that has not failed it returns the original pay-in with 201 (the same checkout.url too, when you asked for checkout), and nothing new is created. A Failed or Expired pay-in is final, and a repeat does not create a new one. To take the payment again, create a new pay-in with a new merchantOrderId.
Request
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
merchantOrderId | string | Required | The merchantOrderId you sent to Create a pay-in, up to 200 characters. Matched exactly: case-sensitive and not trimmed. Send it URL-encoded, see below. |
Encode the merchantOrderId
The value goes in the query string, so encode it as a query component: encodeURIComponent in JavaScript, urllib.parse.quote(value, safe="") in Python, Uri.EscapeDataString in C#, or curl -G --data-urlencode "merchantOrderId=…". These characters change the meaning of the URL if you send them raw:
| Character | Send as | Sent raw |
|---|---|---|
+ | %2B | Read as a space. |
& | %26 | Starts a new parameter, so the id is cut short. |
# | %23 | Starts the fragment. The client does not send the rest of the URL. |
% | %25 | Read as the start of an escape sequence. |
The functions above also encode /, ? and spaces; the server decodes them. The id is a query parameter rather than a path segment because ids can contain /, ., ?, # and %. In a path, an encoded slash (%2F) is not decoded by the server, and HTTP clients and proxies normalise . and .. segments away. A query parameter is decoded the same way for every character.
X-Api-Key, X-Timestamp, X-Signature), as in Authentication. The canonical string for this GET is the empty-body form <X-Timestamp>.. Query parameters are not included in the signature.Response
200 OK with the same body as Get pay-in status returns for that pay-in: starting with payInId. See the response fields and statuses there.
Errors
| HTTP status | Error code | Message |
|---|---|---|
| 400 | VALIDATION_ERROR | "merchantOrderId is required": the parameter is missing or blank. "merchantOrderId exceeds 200 character limit": the value is longer than 200 characters. |
| 404 | NOT_FOUND | "PayIn not found": your account has no pay-in with this merchantOrderId. Another merchant's pay-in with the same id is not visible to you and also gives 404. A first create refused with 400 (for example VALIDATION_ERROR, NO_CARD_AVAILABLE or CLIENT_BLOCKED) creates no pay-in, so its merchantOrderId gives 404 as well. IDEMPOTENCY_KEY_MISMATCH is the exception: it means a pay-in with that merchantOrderId already exists, and the lookup returns it. |
| 401 | See Authentication | Authentication failed. |
Every error code is listed in the error reference.
See also
- Get pay-in status by
payInId, with the response fields and statuses. - Get pay-out status by merchantOrderId, the same lookup for pay-outs.
- Webhook events for
pay_in.completedandpay_in.expired.
TIMESTAMP=$(date +%s) SIGNATURE=$(printf '%s.' "$TIMESTAMP" \ | openssl dgst -sha256 -hmac "$ATLAS_API_SECRET" | sed 's/^.*= //') # -G + --data-urlencode puts the encoded value in the query string curl -G https://api.atlas-online.io/api/merchant/pay-in/status \ --data-urlencode "merchantOrderId=order-12345" \ -H "X-Api-Key: $ATLAS_API_KEY" \ -H "X-Timestamp: $TIMESTAMP" \ -H "X-Signature: $SIGNATURE"
// call() is the helper from Shared helpers async function findPayIn(merchantOrderId) { const q = encodeURIComponent(merchantOrderId); // + → %2B, & → %26 try { return await call('GET', `/api/merchant/pay-in/status?merchantOrderId=${q}`); } catch (err) { if (err.status === 404) return null; // not created throw err; } } // POST /api/merchant/pay-in timed out: const found = await findPayIn(order.merchantOrderId); if (found === null) { // Same merchantOrderId, same body: never a second pay-in. await call('POST', '/api/merchant/pay-in', order); } else { console.log(found.payInId, found.status); // exists: do not create }
import hashlib, hmac, time, urllib.parse import requests def find_pay_in(merchant_order_id: str): ts = str(int(time.time())) sig = hmac.new(API_SECRET.encode(), f"{ts}.".encode(), hashlib.sha256).hexdigest() # GET: empty body q = urllib.parse.quote(merchant_order_id, safe="") r = requests.get( f"{BASE_URL}/api/merchant/pay-in/status?merchantOrderId={q}", headers={"X-Api-Key": API_KEY, "X-Timestamp": ts, "X-Signature": sig}, timeout=30, ) if r.status_code == 404: return None # not created: repeat the create, same body r.raise_for_status() return r.json()
{
"payInId": "1c75c312-93f3-4b34-903e-10fb38a56bec",
"displayId": "PI00000007",
"status": "Completed",
"amount": 5000.00,
"requestedAmount": 5000.00,
"amountAdjustment": 0,
"originalAmount": 5000.00,
"currency": "ARS",
"createdAt": "2026-04-14T13:13:36.837Z",
"expiresAt": "2026-04-14T13:28:36.837Z",
"completedAt": "2026-04-14T13:13:50.651Z",
"expiredAt": null,
"payerName": "Juan Martinez",
"isManualConfirmation": false,
"geo": "Argentina",
"requiresManualConfirmation": false,
"qrPayload": null,
"clientReference": "customer-7781",
"exchangeRate": 1000,
"commissionRate": 5,
"amountUsdt": 4.75
}{
"payInId": "8b2d1f4e-7c9a-4123-a5d6-9e8f0b1c2d3e",
"displayId": "PI00000023",
"status": "Completed",
"amount": 1500.00,
"requestedAmount": 1500.00,
"amountAdjustment": 0,
"originalAmount": 1500.00,
"currency": "VES",
"createdAt": "2026-05-04T14:45:00.000Z",
"expiresAt": "2026-05-04T15:00:00.000Z",
"completedAt": "2026-05-04T14:52:11.204Z",
"expiredAt": null,
"payerName": "Luis Pérez",
"isManualConfirmation": true,
"geo": "Venezuela",
"requiresManualConfirmation": true,
"qrPayload": null,
"clientReference": "customer-7781",
"exchangeRate": 1000,
"commissionRate": 2,
"amountUsdt": 1.47
}{
"payInId": "3f1a9c2b-5d7e-4f80-b1c2-d3e4f5a6b7c8",
"displayId": "PI00000031",
"status": "Completed",
"amount": 4999,
"requestedAmount": 5000,
"amountAdjustment": -1,
"originalAmount": 5000,
"currency": "INR",
"createdAt": "2026-07-31T14:45:00.000Z",
"expiresAt": "2026-07-31T15:00:00.000Z",
"completedAt": "2026-07-31T14:51:07.118Z",
"expiredAt": null,
"payerName": "Amit Verma",
"isManualConfirmation": true,
"geo": "India",
"requiresManualConfirmation": true,
"utr": "0086543219",
"qrPayload": "upi://pay?pa=payer@axl",
"clientReference": "customer-7781",
"exchangeRate": 105.5,
"commissionRate": 8.5,
"amountUsdt": 43.364929
}{
"payInId": "4a2b8d3c-6e8f-4a91-c2d3-e4f5a6b7c8d9",
"displayId": "PI00000032",
"status": "Completed",
"amount": 50000,
"requestedAmount": 50000,
"amountAdjustment": 0,
"originalAmount": 50000,
"currency": "KRW",
"createdAt": "2026-08-07T14:45:00.000Z",
"expiresAt": "2026-08-07T15:00:00.000Z",
"completedAt": "2026-08-07T14:49:33.902Z",
"expiredAt": null,
"payerName": "Park Jisoo",
"isManualConfirmation": true,
"geo": "SouthKorea",
"requiresManualConfirmation": true,
"qrPayload": null,
"clientReference": "customer-7781",
"exchangeRate": 1390,
"commissionRate": 4,
"amountUsdt": 34.532374
}{
"error": "NOT_FOUND",
"message": "PayIn not found"
}{
"error": "VALIDATION_ERROR",
"message": "merchantOrderId is required"
}Cancel a pay-in
/api/merchant/pay-in/{payInId}/cancel/api/merchant/pay-in/cancel?merchantOrderId={merchantOrderId}Cancels a pay-in you no longer need. The pay-in moves to Failed at once and stays there: a payment that arrives later does not complete it. Both forms do the same thing; use the second one when you only have your own merchantOrderId.
New in v2.20.
When to use
Cancel a pay-in when you know the payer will not pay to the details it issued. Two common cases:
- You send the same order to several providers at once and keep whichever answers first. Once you have chosen another provider, cancel ours.
- You never showed the payer our payment details or the hosted payment page, for example because the payer left your checkout first.
Without a cancel the pay-in stays open until it expires. A cancel frees the order right away.
Cancel the order
Call this endpoint with the
payInId, or with themerchantOrderIdyou sent to Create a pay-in.200: cancelled
The response is the pay-in status with
status: Failed. Treat the order as closed on your side. No webhook follows: this response is the confirmation.409: not cancelled
PAYIN_COMPLETEDmeans the payment was received: credit the order as usual.PAYMENT_REPORTEDmeans a payment was reported for the order, so Atlas support settles it; the order goes on normally and can still complete or expire. Do not retry the cancel in either case.CANCEL_FAILEDmeans Atlas could not confirm the cancellation with the payment network right now, so the pay-in is still open: retry after a delay.
A cancelled pay-in is final, and a repeat of Create a pay-in with the same merchantOrderId does not reopen it. To take the payment again, create a new pay-in with a new merchantOrderId.
What can be cancelled
| Current status | Result |
|---|---|
| WaitingPayment | Cancelled: 200, status: Failed. Refused with PAYMENT_REPORTED if a payment was reported for it (see Errors). |
| Expired | Cancelled the same way. An expired pay-in can otherwise still be completed through manual confirmation; after a cancel it cannot. |
| Failed | 200 with the same body, whatever made it fail. Nothing changes, so the call is safe to retry. |
| Completed | Refused with 409 PAYIN_COMPLETED. |
Request
No request body. Send the POST with an empty body.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
payInId | UUID | Required in the first form | Identifier returned by Create a pay-in. |
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
merchantOrderId | string | Required in the second form | The merchantOrderId you sent to Create a pay-in, up to 200 characters. Matched exactly among your own orders: case-sensitive and not trimmed. URL-encode it as a query component, as for Get pay-in status by merchantOrderId. |
X-Api-Key, X-Timestamp, X-Signature), as in Authentication. The body is empty, so the canonical string is <X-Timestamp>., the same as for a GET. Query parameters are not included in the signature.Response
200 OK with the same body as Get pay-in status returns for that pay-in, with status: Failed. See the response fields there.
Errors
| HTTP status | Error code | Message |
|---|---|---|
| 409 | PAYIN_COMPLETED | "{displayId} is completed — a completed pay-in cannot be cancelled.": the payment was received. Credit the order. Do not retry. |
| 409 | PAYMENT_REPORTED | "A payment was reported for {displayId} — it is settled by Atlas support and cannot be cancelled.": Atlas has a report that the payer paid this order: a UTR is on file (sent by you with Submit UTR, entered by the payer on the hosted page, or recorded by Atlas), the payer tapped "I have paid" on the hosted page,: a payer reference is on file (sent by you with Submit a payer reference, or recorded by Atlas), the payer tapped "I have paid" on the hosted page, or a payment check or a dispute is open on it. The order goes on normally and can still complete or expire. Do not retry. |
| 409 | CANCEL_FAILED | "{displayId} could not be cancelled right now — it stays open. Retry later.": Atlas could not confirm the cancellation with the payment network right now. The pay-in stays WaitingPayment; in the meantime it can still complete or expire as usual. Retrying later is safe. |
| 409 | CONCURRENT_UPDATE | The request collided with another change to the same order and was not completed. Retry after a short delay. |
| 400 | VALIDATION_ERROR | Second form only. "merchantOrderId is required": the parameter is missing or blank. "merchantOrderId exceeds 200 character limit": the value is longer than 200 characters. |
| 404 | NOT_FOUND | "PayIn not found": your account has no pay-in with this payInId or merchantOrderId. Another merchant's pay-in is not visible to you and also gives 404. |
| 401 | See Authentication | Authentication failed. |
Every error code is listed in the error reference.
See also
- Get pay-in status for the response fields and the statuses.
- Get pay-in status by merchantOrderId for the encoding rules of
merchantOrderId. - Webhook events: a cancel sends no
pay_in.failed.
TIMESTAMP=$(date +%s) SIGNATURE=$(printf '%s.' "$TIMESTAMP" \ | openssl dgst -sha256 -hmac "$ATLAS_API_SECRET" | sed 's/^.*= //') # By payInId curl -X POST https://api.atlas-online.io/api/merchant/pay-in/$PAY_IN_ID/cancel \ -H "X-Api-Key: $ATLAS_API_KEY" \ -H "X-Timestamp: $TIMESTAMP" \ -H "X-Signature: $SIGNATURE" # By merchantOrderId: -G puts the encoded value in the query string, # -X POST keeps the method curl -G -X POST https://api.atlas-online.io/api/merchant/pay-in/cancel \ --data-urlencode "merchantOrderId=order-12345" \ -H "X-Api-Key: $ATLAS_API_KEY" \ -H "X-Timestamp: $TIMESTAMP" \ -H "X-Signature: $SIGNATURE"
// call() is the helper from Shared helpers async function cancelPayIn(merchantOrderId) { const q = encodeURIComponent(merchantOrderId); // + → %2B, & → %26 try { const s = await call('POST', `/api/merchant/pay-in/cancel?merchantOrderId=${q}`); // no body return { cancelled: true, status: s.status }; // "Failed" } catch (err) { if (err.message === 'PAYIN_COMPLETED') return { cancelled: false, credit: true }; // money arrived if (err.message === 'PAYMENT_REPORTED') return { cancelled: false }; // keep waiting for the webhook throw err; // CANCEL_FAILED, CONCURRENT_UPDATE, 5xx: retry later } } // Another provider answered first: await cancelPayIn(order.merchantOrderId);
import hashlib, hmac, time, urllib.parse import requests def cancel_pay_in(merchant_order_id: str): ts = str(int(time.time())) sig = hmac.new(API_SECRET.encode(), f"{ts}.".encode(), hashlib.sha256).hexdigest() # empty body q = urllib.parse.quote(merchant_order_id, safe="") r = requests.post( f"{BASE_URL}/api/merchant/pay-in/cancel?merchantOrderId={q}", headers={"X-Api-Key": API_KEY, "X-Timestamp": ts, "X-Signature": sig}, timeout=30, ) if r.status_code == 409: code = r.json()["error"] if code in ("PAYIN_COMPLETED", "PAYMENT_REPORTED"): return code # not cancelled: do not retry r.raise_for_status() # CANCEL_FAILED, CONCURRENT_UPDATE: retry later return r.json() # status == "Failed"
{
"payInId": "5e7f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
"displayId": "PI00000041",
"status": "Failed",
"amount": 5000.00,
"requestedAmount": 5000.00,
"amountAdjustment": 0,
"originalAmount": 5000.00,
"currency": "ARS",
"createdAt": "2026-11-04T10:00:00.000Z",
"expiresAt": "2026-11-04T10:15:00.000Z",
"completedAt": null,
"expiredAt": null,
"payerName": null,
"isManualConfirmation": false,
"geo": "Argentina",
"requiresManualConfirmation": false,
"qrPayload": null,
"clientReference": "customer-7781",
"exchangeRate": 1000,
"commissionRate": 5,
"amountUsdt": 4.75
}{
"payInId": "5e7f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
"displayId": "PI00000041",
"status": "Failed",
"amount": 1500.00,
"requestedAmount": 1500.00,
"amountAdjustment": 0,
"originalAmount": 1500.00,
"currency": "VES",
"createdAt": "2026-11-04T10:00:00.000Z",
"expiresAt": "2026-11-04T10:15:00.000Z",
"completedAt": null,
"expiredAt": null,
"payerName": null,
"isManualConfirmation": true,
"geo": "Venezuela",
"requiresManualConfirmation": true,
"qrPayload": null,
"clientReference": "customer-7781",
"exchangeRate": 1000,
"commissionRate": 2,
"amountUsdt": 1.47
}{
"payInId": "5e7f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
"displayId": "PI00000041",
"status": "Failed",
"amount": 5000,
"requestedAmount": 5000,
"amountAdjustment": 0,
"originalAmount": 5000,
"currency": "INR",
"createdAt": "2026-11-04T10:00:00.000Z",
"expiresAt": "2026-11-04T10:15:00.000Z",
"completedAt": null,
"expiredAt": null,
"payerName": null,
"isManualConfirmation": true,
"geo": "India",
"requiresManualConfirmation": true,
"utr": null,
"qrPayload": "upi://pay?pa=payee@axl",
"clientReference": "customer-7781",
"exchangeRate": 105.5,
"commissionRate": 8.5,
"amountUsdt": 43.364929
}{
"payInId": "5e7f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
"displayId": "PI00000041",
"status": "Failed",
"amount": 50000,
"requestedAmount": 50000,
"amountAdjustment": 0,
"originalAmount": 50000,
"currency": "KRW",
"createdAt": "2026-11-04T10:00:00.000Z",
"expiresAt": "2026-11-04T10:15:00.000Z",
"completedAt": null,
"expiredAt": null,
"payerName": null,
"isManualConfirmation": true,
"geo": "SouthKorea",
"requiresManualConfirmation": true,
"qrPayload": null,
"clientReference": "customer-7781",
"exchangeRate": 1390,
"commissionRate": 4,
"amountUsdt": 34.532374
}{
"error": "PAYMENT_REPORTED",
"message": "A payment was reported for PI00000041 — it is settled by Atlas support and cannot be cancelled."
}{
"error": "PAYIN_COMPLETED",
"message": "PI00000041 is completed — a completed pay-in cannot be cancelled."
}{
"error": "NOT_FOUND",
"message": "PayIn not found"
}{
"error": "VALIDATION_ERROR",
"message": "merchantOrderId is required"
}Submit UTRSubmit a payer reference
/api/merchant/pay-in/{payInId}/utrRecords the Unique Transaction Reference (UTR) the payer gave you at checkout on the pay-in, and returns the pay-in's current status in the same call.Records a transfer reference the payer gave you at checkout on the pay-in, and returns the pay-in's current status in the same call. The request field is named utr.
When to use
Call it whenever the payer gives you the UTR after the UPI or IMPS transfer. Atlas sees the UTR during reconciliation, and it helps match the incoming transfer to this exact order when two payments carry the same amount.
Relaying a payer-reported transfer reference is optional in South Korea. Neither Korean rail carries a transaction reference of its own, and a Korean pay-in confirms without one. When your checkout does collect a reference from the payer, send it here: the platform stores it on the pay-in and uses it as extra matching context against the receiving account's bank or Kakao Pay statement.
The response carries the current status, which suits you if you prefer to pull status synchronously instead of waiting for the pay_in.completed webhook.
WaitingPayment or Expired. In any other status the call stores nothing and returns the current state.
Request
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
payInId | UUID | Required | Identifier of the pay-in, from Create a pay-in. |
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
utr | string | Required | Transfer reference reported by the payer, as issued by their bank. Trimmed; up to 128 characters. |
Response
Response fields · 200 OK
| Field | Type | Description |
|---|---|---|
payInId | UUID | Platform identifier of the pay-in. |
displayId | string | Human-readable identifier. |
status | enum | Current pay-in status. See Get pay-in status. |
utr | string | Echo of the recorded UTRreference. |
requiresManualConfirmation | boolean | The same flag as on Create a pay-in: true when the payment is confirmed on the Atlas side. Nothing to do on your side. |
Errors
An authentication failure returns 401; see Authentication. Error codes and their meaning are listed in the error reference.
See also
- Webhook events: credit the order on
pay_in.completed. - Payment screen options for the ways to present a UPI order before you collect the UTR.
- UPI, IMPS and India on atlas-online.io.
- Korean bank transfer, Kakao Pay and South Korea on atlas-online.io.
{ "utr": "0086543219" }
{
"payInId": "1c75c312-93f3-4b34-903e-10fb38a56bec",
"displayId": "PI00000007",
"status": "WaitingPayment",
"utr": "0086543219",
"requiresManualConfirmation": true
}
Payment screen options
On a host-to-host integration you draw the payment screen yourself from the Create a pay-in response. This page maps each response key to what you show the payer.
When to use
Read this before you build your own screen for a UPI or IMPS order. If you would rather not build one, send checkout on the same request and redirect the payer to checkout.url (hosted checkout), or use the embedded checkout.
Nothing here is a separate endpoint. It uses the same create call and the same authentication; only the optional includeUpiAppLinks flag and the upiAppLinks object it turns on are specific to this page.
Response keys by method
| Method | Keys | What to render |
|---|---|---|
upi, upi_intent | qrImageBase64, qrPayload | The QR. Use the image as is, or draw your own QR from the payload. |
upi, upi_intent | upiVpa, amount | The UPI ID and the amount as text with copy buttons. |
upi, upi_intent | upiIntentLink | A "Pay in UPI app" button. Present on every UPI-rail order served by a receiving account in the Atlas network. |
upi, upi_intent | upiAppLinks.phonepe, upiAppLinks.paytm | PhonePe and Paytm buttons. Only when you sent includeUpiAppLinks: true. |
imps | accountNumber, ifscCode, bankName, ownerName, amount | Bank details for a manual IMPS transfer. No QR and no deep links. |
Always show amount exactly as returned: it may differ from what you requested (see amount adjustment).
Four ways to present a UPI order
A UPI order (paymentMethod upi or upi_intent) gives you four ways to hand the payer the payment details. They all point at the same VPA and the same amount, so you can show several on one screen and let the payer pick.
| Option | What the payer does | Response fields | Where it works |
|---|---|---|---|
| QR code | Scans the QR with any UPI app and confirms the pre-filled payment. | qrImageBase64, qrPayload | Everywhere. On a desktop it is the main option: the payer scans the screen with a phone. |
| UPI ID | Copies the VPA and the amount and types them into their UPI app by hand. | upiVpa, amount | Everywhere. The slowest option and the only one where the payer can mistype the amount, so keep it as the last fallback. |
| UPI Intent | Taps one button. The phone offers the installed UPI apps, and the chosen one opens with payee, amount and currency filled in. | upiIntentLink (upi://pay?…) | Phones with a UPI app, mostly Android. |
| App deep links (quasi-intent) | Taps PhonePe or Paytm. That app opens its own "send money to a UPI ID" screen with the payee and the locked amount filled in. | upiAppLinks, on request (v2.14) | Phones with that app installed. |
Why the app deep links exist
A upi://pay intent tells the payer's app that a third party is requesting the payment. Our payment details are person-to-person VPAs, not verified merchant VPAs, and some payer banks decline an intent addressed to a P2P VPA.
A deep link into the app's own send-money screen avoids that. The result is an ordinary P2P transfer, the same one the payer would make by typing the VPA by hand, with nothing left to type. That is why we call it a quasi-intent: to the payer it looks like an intent (one tap, everything pre-filled), and to the bank it looks like a manual transfer.
The amount is locked in both apps. PhonePe receives it in paise with amount editing disabled, Paytm in rupees with two decimals. Both links carry the payable amount from the same response, including any amount adjustment, so the payer cannot send a different sum than the one the order is matched on.
upiIntentLink, the app links do not work inside a nested iframe or most in-app WebViews, so open your payment screen in the payer's browser.
Request the deep links
| Name | Type | Required | Description |
|---|---|---|---|
includeUpiAppLinks | boolean | Optional | Send "includeUpiAppLinks": true in the Create a pay-in body to get upiAppLinks. |
When the order is served on the UPI rail by a receiving account in the Atlas network, the response carries an extra upiAppLinks object right before checkout. The flag never makes a request fail: on an imps order or an order routed to an external provider it is ignored and the key is omitted, exactly as upiIntentLink is. A request without the flag gets the same response as before v2.14, byte for byte, so existing integrations are not affected.
Treat both links as opaque strings. Put them into the href of a button as they are; do not build them yourself and do not change their parameters. An idempotent replay of the same merchantOrderId returns the same links when the replay also sends the flag. The links are not part of GET /pay-in/{payInId}/status or of the webhooks.
A layout that works
Our own payment page (hosted and embedded checkout) renders every UPI order, upi and upi_intent alike, as three choices on phones and desktops:
- PhonePe →
upiAppLinks.phonepe - Paytm →
upiAppLinks.paytm - Other UPI → unfolds the QR and the UPI ID with copy buttons, for any other UPI app
The page does not offer the generic upiIntentLink as a separate button, because on our P2P VPAs the app deep links and the QR go through more reliably. You can still use it on your own screen. An imps order shows account number, IFSC, bank and account holder. If you would rather not build any of this, send checkout on the same request and redirect the payer to checkout.url.
Confirmation does not change
Whichever option the payer uses, the money reaches the same VPA for the same order. Credit the order from the pay_in.completed webhook, as for any UPI pay-in. If the payer reports a UTR, you can relay it with POST /pay-in/{payInId}/utr as matching context; it is not required for confirmation.
See also
- Create a pay-in for the full response field reference.
- Webhook events for
pay_in.completed. - UPI, IMPS and India on atlas-online.io.
- H2H API overview on atlas-online.io.
{
"amount": 3049,
"currency": "INR",
"geo": 3,
"expiresIn": 900,
"merchantOrderId": "order-in-intent-12345",
"clientReference": "customer-7781",
"paymentMethod": "upi_intent",
"includeUpiAppLinks": true
}{
"payInId": "6b7c8d9e-0f12-4a34-b5c6-d7e8f9a0b1c2",
"displayId": "PI00000991",
"geo": "India",
"ownerName": "Ravi Sharma",
"bankName": "Yes Bank",
"upiVpa": "ravi.sharma@ybl",
"upiIntentLink": "upi://pay?pa=ravi.sharma%40ybl&pn=Ravi%20Sharma&am=3049.00&cu=INR",
"amount": 3049,
"requestedAmount": 3049,
"amountAdjustment": 0,
"currency": "INR",
"expiresAt": "2026-10-01T09:15:00.000Z",
"requiresManualConfirmation": true,
"qrImageBase64": "data:image/png;base64,iVBORw0KGgo...",
"upiAppLinks": {
"phonepe": "phonepe://native?data=eyJjb250YWN0Ijp7ImNic05hbWUiOiIiLCJuaWNrTmFtZSI6IiIsInZwYSI6InJhdmkuc2hhcm1hQHlibCIsInR5cGUiOiJWUEEifSwicDJwUGF5bWVudENoZWNrb3V0UGFyYW1zIjp7Im5vdGUiOiIiLCJpc0J5RGVmYXVsdEtub3duQ29udGFjdCI6dHJ1ZSwiZW5hYmxlU3BlZWNoVG9UZXh0IjpmYWxzZSwiYWxsb3dBbW91bnRFZGl0IjpmYWxzZSwic2hvd1FyQ29kZU9wdGlvbiI6ZmFsc2UsImRpc2FibGVWaWV3SGlzdG9yeSI6dHJ1ZSwic2hvdWxkU2hvd1Vuc2F2ZWRDb250YWN0QmFubmVyIjpmYWxzZSwiaXNSZWN1cnJpbmciOmZhbHNlLCJjaGVja291dFR5cGUiOiJERUZBVUxUIiwidHJhbnNhY3Rpb25Db250ZXh0IjoicDJwIiwiaW5pdGlhbEFtb3VudCI6MzA0OTAwLCJkaXNhYmxlTm90ZXNFZGl0Ijp0cnVlLCJzaG93S2V5Ym9hcmQiOmZhbHNlLCJjdXJyZW5jeSI6IklOUiIsInNob3VsZFNob3dNYXNrZWROdW1iZXIiOnRydWV9fQ%3D%3D&id=p2ppayment",
"paytm": "paytmmp://cash_wallet?pa=ravi.sharma%40ybl&am=3049.00&cu=INR&featuretype=money_transfer"
},
"exchangeRate": 105.5,
"commissionRate": 8.5,
"amountUsdt": 26.443934
}<a class="pay-app" href="{upiAppLinks.phonepe}">PhonePe</a>
<a class="pay-app" href="{upiAppLinks.paytm}">Paytm</a>
<details>
<summary>Other UPI</summary>
<img src="{qrImageBase64}" alt="UPI QR">
<p>{upiVpa} · ₹{amount}</p>
</details>
Hosted checkout
Add a checkout object to POST /api/merchant/pay-in and Atlas returns a link to an Atlas-branded payment page that it hosts for you.
Use it instead of rendering the recipient details (the VPA / QRthe account number or the Kakao Pay QR) in your own UI. The page shows the QR / UPI ID, collects an optional UTR from the payer and polls for confirmation. Once Atlas confirms the payment, it closes into success on its own, even if the payer never returns to the page.The page shows the recipient's account details or the Kakao Pay QR and polls for confirmation. Once Atlas confirms the payment, it closes into success on its own, even if the payer never returns to the page.
Added in v2.5, scope widened in v2.6.Added in v2.8. Product pages: Pay page, Payment link, Payments in India, Payments in South Korea.
Quick start
Create the pay-in with a
checkoutobjectSend
POST /api/merchant/pay-infrom your server, signed as usual.checkout.returnUrlis the only required member. The request samples on the right show one body per method.Send the payer to
checkout.urlRedirect or link to it. To keep the payer on your own page, mount
checkout.embedUrlinstead (see Embedded checkout).Handle the return
Every outcome lands on
returnUrlwithpayInIdandmerchantOrderIdin the query string. Look up the status there; the redirect itself proves nothing.Credit the order on the webhook
Fulfil on the signed
pay_in.completedwebhook or on Get pay-in status.
Markets and methods
checkout is available for India (upi, upi_intent, imps) on orders served in the Atlas network. One page serves all three methods and adapts to the one you ask for.
checkout is available for South Korea (kor_bank, kakao_pay) on orders served in the Atlas network. One page serves both methods and adapts to the one you ask for.
| Method | What the page shows |
|---|---|
upi | A choice of PhonePe, Paytm and Other UPI, which unfolds the QR and the UPI ID. See Payment screen options. |
upi_intent | The same choice as upi: PhonePe, Paytm and Other UPI, which unfolds the QR and the UPI ID. |
imps | Account number, IFSC, bank and account holder. No QR and no deep link. |
kor_bank | Account number, bank and account holder. No QR and no deep link. |
kakao_pay | The receiving account's Kakao Pay QR, plus a tap-to-open button when that payload is a kakaopay.com link. |
On these pages the payer may enter a UTR to speed up confirmation. It is not required: the page closes into success once Atlas confirms the payment.
These pages have no reference step at all: the payer confirms and Atlas matches the deposit.
checkout is present, expiresIn must be within 300–3600 seconds. Any other combination of market, method and routing is rejected with 400 CHECKOUT_NOT_SUPPORTED, and the pay-in is not created. See Errors below.The checkout request object
| Name | Type | Required | Description |
|---|---|---|---|
returnUrl | string | Required if checkout present | Absolute HTTPS URL (http only in Development), at most 1024 characters, no userinfo, no fragment. The payer is sent here for every outcome; see Redirect outcomes. The literal placeholder {PAY_IN_ID} is replaced with the created payInId. |
cancelUrl | string | Optional | Same validation rules as returnUrl. Used only when the payer cancels explicitly on the hosted page. Falls back to returnUrl when omitted. |
locale | string | Optional | BCP-47 base language, for example hi, enko, en. An unknown value is never an error: it falls back to the geo default, and checkout.locale echoes the locale actually served. |
The checkout response object
| Field | Type | Description |
|---|---|---|
url | string | The hosted payment page. Redirect or link the payer here. Present only when checkout was requested and accepted; otherwise the whole checkout key is omitted and the response is byte-identical to what a v2.4 caller gets. An idempotent replay of the same merchantOrderId returns the same url. |
expiresAt | datetime | Snapshot taken at creation time. It is not the source of truth; see the expiry note below. |
locale | string | The locale served on the hosted page, after the geo-default fallback. |
embedUrl | string | Added in v2.7. The same page in embeddable form: the value that goes into Atlas.mount('#atlas-pay', { url: … }). It is appended after locale, and the three members above never change order. url and embedUrl are alternatives: redirect to one or embed the other. See Embedded checkout. |
Lifecycle and return URL
returnUrl is a navigation hint, not a payment result, and the payer's browser may never reach it (closed tab, app switch, network loss). Credit the order only from the signed pay_in.completed webhook or from GET /api/merchant/pay-in/{payInId}/status. Non-success outcomes land on returnUrl too, so your handler must cope with every status (Completed / WaitingPayment / Expired / Failed) and be idempotent by payInId. A retry is a new pay-in with a new merchantOrderId, not a resubmission of the old one.
Redirect outcomes
| Outcome | Destination | Notes |
|---|---|---|
| Success (Atlas confirmed the payment) | returnUrl | 302 GET returnUrl?payInId=…&merchantOrderId=…. Confirm the actual status through the webhook or GET status before crediting. |
| Not confirmed / "start again" | returnUrl | Same query string. You decide whether to offer a new pay-in. |
| Expired | returnUrl | Same query string. The URL does not distinguish expiry; call GET status for the reason. |
| Explicit payer cancel | cancelUrl (falls back to returnUrl) | The only outcome that targets cancelUrl. The status does not change; the pay-in is left to expire normally. |
The redirect query string never includes a status or a signature, only payInId and merchantOrderId.
Expiry
expiresAt may be extended once by up to 30 minutes,When the payer confirms on the hosted page that they have paid, expiresAt may be extended once by up to 30 minutes, and the matching pay_in.expired webhook (if any) arrives later accordingly. Do not build an expiry countdown from checkout.expiresAt in the create response. Poll Get pay-in status or rely on the webhook.
Payer flow
The payer pays by scanning the QR code, by copying the UPI ID and amount into their own UPI app, or by tapping PhonePe or Paytm, which opens that app with the payee and the amount filled in. To speed up confirmation they may tap "I have paid" and enter a 12-digit UTR. The UTR is not required: the page keeps polling and closes into success as soon as Atlas confirms the payment, even if the payer does nothing more on the page.
The payer pays by transferring the amount to the account shown on the page (kor_bank), or by scanning the Kakao Pay QR (kakao_pay); tapping the button opens it directly when the payload is a kakaopay.com link. They may tap "I have paid" to say they are done. The page never asks them for a reference, and the tap is not required either: the page keeps polling and closes into success as soon as Atlas confirms the payment, even if the payer does nothing more on the page.
checkout.embedUrl, which renders this page, theme included, in a cross-origin <iframe> on your own checkout screen instead of navigating away. See Embedded checkout.
Test on the sandbox
You can try the page without writing integration code. In the sandbox merchant dashboard open Pay-Ins → Test Pay Page (the production dashboard does not have this page), set an amount and press Create payment link. The dashboard returns the /pay/{token} URL with a QR of that URL (scan it to open the page on a real phone), then shows the pay-in's live status while you walk through the payer steps and the confirmation.
The result is an ordinary pay-in, with the same routing, spreads, statuses, expiry and webhooks as one created over the API, so what you observe there is what your own integration will see. To drive it over HTTP instead, use the ready-made Postman request Create PayIn (India, hosted checkout).
Errors
| Code | HTTP status | When | What to do |
|---|---|---|---|
CHECKOUT_NOT_SUPPORTED | 400 | The market, method or routing is outside the scope. The message lists every method checkout accepts: "checkout is currently available for India (paymentMethod upi, upi_intent or imps) … only." | The pay-in is not created. Retry without checkout, or wait for the gate to be lifted for your geo. |
CHECKOUT_NOT_SUPPORTED | 400 | The market, method or routing is outside the scope. The message lists every method checkout accepts: "checkout is currently available for … South Korea (paymentMethod kor_bank or kakao_pay) only." | The pay-in is not created. Retry without checkout, or wait for the gate to be lifted for your geo. |
CHECKOUT_NOT_SUPPORTED | 400 | The order would be served by an external provider. The message reads "checkout is not available for this merchant's routing …" | The pay-in is not created. Retry without checkout, or wait for the gate to be lifted for your geo. |
CLIENT_BLOCKED | 400 | New in v2.18. Atlas has blocked new pay-ins from this clientReference on your account (see Blocked clients). | The pay-in is not created and the response has no checkout object, so there is no checkout.url and no page to send the payer to. Show the payer a neutral message in your own UI, for example “Payment is unavailable. Please contact the shop's support.” Do not retry, with or without checkout. |
Browser-facing page endpoints
These serve the hosted page itself, for the payer's browser only. They are not HMAC-authenticated and not part of the merchant API; do not call them from your integration.
| Method | Path | Purpose |
|---|---|---|
| GET | /pay/{token} | The hosted payment page (HTML). |
| GET | /api/checkout/{token} | Page view-model (amount, VPA / QR, expiry, locale) read by the page's own JS. |
| GET | /api/checkout/{token} | Page view-model (amount, account details or QR, expiry, locale) read by the page's own JS. |
| GET | /api/checkout/{token}/status | Polled by the page for live status. |
| POST | /api/checkout/{token}/claim | "I have paid". Body { utr?: string }; the UTR is optional. |
| POST | /api/checkout/{token}/claim | "I have paid", the payer's own confirmation. The body carries no reference, because the page never asks for one. |
| GET | /pay/{token}/return?reason=done|cancel | Issues the 302 back to the merchant's returnUrl/cancelUrl. |
{
"amount": 3049,
"currency": "INR",
"geo": 3,
"expiresIn": 900,
"merchantOrderId": "order-in-checkout-12345",
"clientReference": "customer-7781",
"paymentMethod": "upi",
"checkout": {
"returnUrl": "https://shop.example/pay/done?o={PAY_IN_ID}",
"cancelUrl": "https://shop.example/cart",
"locale": "hi"
}
}{
"amount": 3049,
"currency": "INR",
"geo": 3,
"expiresIn": 900,
"merchantOrderId": "order-in-checkout-12346",
"clientReference": "customer-7781",
"paymentMethod": "upi_intent",
"checkout": {
"returnUrl": "https://shop.example/pay/done?o={PAY_IN_ID}",
"cancelUrl": "https://shop.example/cart",
"locale": "hi"
}
}{
"amount": 5000,
"currency": "INR",
"geo": 3,
"expiresIn": 900,
"merchantOrderId": "order-in-checkout-12347",
"clientReference": "customer-7781",
"paymentMethod": "imps",
"checkout": {
"returnUrl": "https://shop.example/pay/done?o={PAY_IN_ID}",
"cancelUrl": "https://shop.example/cart",
"locale": "en"
}
}{
"amount": 50000,
"currency": "KRW",
"geo": 4,
"expiresIn": 900,
"merchantOrderId": "order-kr-checkout-12345",
"clientReference": "customer-7781",
"paymentMethod": "kor_bank",
"checkout": {
"returnUrl": "https://shop.example/pay/done?o={PAY_IN_ID}",
"cancelUrl": "https://shop.example/cart",
"locale": "ko"
}
}{
"amount": 50000,
"currency": "KRW",
"geo": 4,
"expiresIn": 900,
"merchantOrderId": "order-kr-checkout-12346",
"clientReference": "customer-7781",
"paymentMethod": "kakao_pay",
"checkout": {
"returnUrl": "https://shop.example/pay/done?o={PAY_IN_ID}",
"cancelUrl": "https://shop.example/cart",
"locale": "ko"
}
}{
"payInId": "5e6f7a8b-9c0d-41e2-b3f4-a5b6c7d8e9f0",
"displayId": "PI00000991",
"geo": "India",
"ownerName": "Ravi Sharma",
"bankName": "Yes Bank",
"upiVpa": "ravi.sharma@ybl",
"upiIntentLink": "upi://pay?pa=ravi.sharma%40ybl&pn=Ravi%20Sharma&am=3049.00&cu=INR",
"amount": 3049,
"currency": "INR",
"expiresAt": "2026-09-07T09:15:00.000Z",
"requiresManualConfirmation": true,
"qrImageBase64": "data:image/png;base64,iVBORw0KGgo...",
"qrPayload": "upi://pay?pa=ravi.sharma@ybl&pn=Ravi%20Sharma&am=3049.00&cu=INR",
"checkout": {
"url": "https://api.atlas-online.io/pay/AbC9fQ2...",
"expiresAt": "2026-09-07T09:15:00.000Z",
"locale": "hi",
"embedUrl": "https://api.atlas-online.io/embed/AbC9fQ2..."
},
"exchangeRate": 105.5,
"commissionRate": 8.5,
"amountUsdt": 26.443934
}{
"payInId": "8d9e0f12-3456-4c78-d9e0-f1a2b3c4d5e6",
"displayId": "PI00000993",
"geo": "India",
"ownerName": "Ravi Sharma",
"bankName": "Yes Bank",
"upiVpa": "ravi.sharma@ybl",
"upiIntentLink": "upi://pay?pa=ravi.sharma%40ybl&pn=Ravi%20Sharma&am=3049.00&cu=INR",
"amount": 3049,
"currency": "INR",
"expiresAt": "2026-09-07T09:15:00.000Z",
"requiresManualConfirmation": true,
"qrImageBase64": "data:image/png;base64,iVBORw0KGgo...",
"qrPayload": "upi://pay?pa=ravi.sharma@ybl&pn=Ravi%20Sharma&am=3049.00&cu=INR",
"checkout": {
"url": "https://api.atlas-online.io/pay/Rn7xW4p...",
"expiresAt": "2026-09-07T09:15:00.000Z",
"locale": "hi",
"embedUrl": "https://api.atlas-online.io/embed/Rn7xW4p..."
},
"exchangeRate": 105.5,
"commissionRate": 8.5,
"amountUsdt": 26.443934
}{
"payInId": "7c8d9e0f-1234-4b56-c7d8-e9f0a1b2c3d4",
"displayId": "PI00000992",
"geo": "India",
"ownerName": "Ravi Kumar",
"bankName": "HDFC Bank",
"accountNumber": "40145477128",
"ifscCode": "HDFC0001234",
"amount": 5000,
"currency": "INR",
"expiresAt": "2026-09-07T09:15:00.000Z",
"requiresManualConfirmation": true,
"checkout": {
"url": "https://api.atlas-online.io/pay/Kd3mZ8t...",
"expiresAt": "2026-09-07T09:15:00.000Z",
"locale": "en",
"embedUrl": "https://api.atlas-online.io/embed/Kd3mZ8t..."
},
"exchangeRate": 105.5,
"commissionRate": 8.5,
"amountUsdt": 43.364929
}{
"payInId": "4a2b8d3c-6e8f-4a91-c2d3-e4f5a6b7c8d9",
"displayId": "PI00000032",
"geo": "SouthKorea",
"ownerName": "Kim Minseo",
"bankName": "KB Kookmin Bank",
"accountNumber": "110123456789",
"amount": 50000,
"currency": "KRW",
"expiresAt": "2026-09-07T09:15:00.000Z",
"requiresManualConfirmation": true,
"checkout": {
"url": "https://api.atlas-online.io/pay/AbC9fQ2...",
"expiresAt": "2026-09-07T09:15:00.000Z",
"locale": "ko",
"embedUrl": "https://api.atlas-online.io/embed/AbC9fQ2..."
},
"exchangeRate": 1390,
"commissionRate": 4,
"amountUsdt": 34.532374
}{
"payInId": "5c3d9e4f-7a9b-4b02-d3e4-f5a6b7c8d9e0",
"displayId": "PI00000033",
"geo": "SouthKorea",
"ownerName": "Kim Minseo",
"bankName": "Kakao Pay",
"amount": 50000,
"currency": "KRW",
"expiresAt": "2026-09-07T09:15:00.000Z",
"requiresManualConfirmation": true,
"qrImageBase64": "data:image/png;base64,iVBORw0KGgo...",
"qrPayload": "https://qr.kakaopay.com/AbC9fQ2",
"checkout": {
"url": "https://api.atlas-online.io/pay/Zt5yH8k...",
"expiresAt": "2026-09-07T09:15:00.000Z",
"locale": "ko",
"embedUrl": "https://api.atlas-online.io/embed/Zt5yH8k..."
},
"exchangeRate": 1390,
"commissionRate": 4,
"amountUsdt": 34.532374
}{
"error": "CHECKOUT_NOT_SUPPORTED",
"message": "checkout is not available for this merchant's routing — the order would be served by an external provider. Retry without `checkout`."
}{
"error": "CHECKOUT_NOT_SUPPORTED",
"message": "checkout is not available for this merchant's routing — the order would be served by an external provider. Retry without `checkout`."
}Embedded checkout
Show the hosted checkout page inside your own page instead of sending the payer to it.
The page loads in a cross-origin <iframe> that resizes to fit its content and reports what happens through JavaScript events, so the payer never leaves your checkout. The pay-in, the confirmation flow and the webhooks are the same as for the hosted page; only the surface differs. Like the hosted page, it is available for India (upi, upi_intent, imps)South Korea (kor_bank, kakao_pay).
Added in v2.7. Product pages: Embedded checkout, Payments in India, Payments in South Korea.
In the API contract the whole feature is one new field: checkout.embedUrl on the POST /api/merchant/pay-in response. The loader and the events live in the browser, the theme and the allowed domains are settings on your merchant account, and this section documents them.
success event is a UI hint, not a payment. Fulfil the order on the signed pay_in.completed webhook or on GET /api/merchant/pay-in/{payInId}/status. The payer's browser can close before the event fires, and the merchant page can be compromised. The redirect flow follows the same rule.
Quick start
Create the pay-in on your server
Exactly as for the hosted page:
POST /api/merchant/pay-inwith acheckoutobject (returnUrlrequired;cancelUrlandlocaleoptional). The same scope gate applies: India (upi/upi_intent/imps)South Korea (kor_bank/kakao_pay), orders served in the Atlas network, andexpiresInwithin 300–3600 s.Register your domain
The page refuses to be framed by an origin you have not registered. Save the list once per account; see Allowed embedding domains.
Put
checkout.embedUrlinto the pageRender it into the page you serve to that payer. It is a one-shot URL scoped to that single pay-in: do not cache it and do not reuse it for another order.
Mount it
Add a container, load the loader and call
Atlas.mount(). The full example with a theme and event handlers is in the samples for this section.
<div id="atlas-pay"></div>
<script src="https://api.atlas-online.io/embed/v1/atlas.js"></script>
<script>
Atlas.mount('#atlas-pay', { url: checkout.embedUrl });
</script>
url and embedUrl are alternatives, not a sequence: send the payer to url or embed embedUrl. Both point at the same pay-in and the same state; opening both only shows the same page twice.
Atlas.mount(target, options)
target is a CSS selector or an Element. The call returns an instance. It throws only on programmer error: a target that does not exist, or neither url nor token.
| Option | Default | Description |
|---|---|---|
url | — | The checkout.embedUrl returned with the pay-in. Preferred: it already carries the right host and token. |
token | — | The checkout token alone. The frame URL is then <origin of atlas.js>/embed/<token>, so this works only when the loader is served from the API host. Pass either url or token. |
appearance | — | The full appearance object (see Appearance and branding): {version, theme, variables, rules, branding, background, display, texts, variablesDark, layout, locale, languageSwitcher}. |
branding | — | Shorthand, merged into appearance.branding. |
layout | — | Shorthand: 'card' | 'compact'. |
locale | — | Shorthand: 'auto' | 'en' | 'hi'. |
locale | — | Shorthand: 'auto' | 'en' | 'ko'. |
languageSwitcher | false | Show the in-frame language pill. Off by default in an embed, because the language of your page is yours to choose. |
height | 'auto' | 'auto' follows the frame's content; a number pins the height in px and turns the follow off. |
onEvent | — | function (type, payload): one callback for every event, alongside .on(). |
Every theme option is optional. Without any of them, the frame renders the theme saved on your merchant account, applied server-side before the first paint (no flash).
Instance API
| Call | What it does |
|---|---|
instance.on(event, handler) | Subscribe. handler(payload, type); returns the instance, so calls chain. |
instance.off(event[, handler]) | Drop one handler, or all handlers for that event. |
instance.refresh() | Poll the payment status now, for example when the payer comes back from their UPI app. |
instance.refresh() | Poll the payment status now, for example when the payer comes back from their banking or Kakao Pay app. |
instance.setAppearance(a) | Live re-theme without a reload. Each call updates only the parts it names. |
instance.setLocale('hi') | Live language change. |
instance.setLocale('ko') | Live language change. |
instance.focus() | Move keyboard focus into the frame. |
instance.unmount() | Remove the iframe, close the channel, drop every handler. |
instance.iframe | The <iframe> element itself. |
Calls made before the frame answers are queued and delivered right after ready, so you can call setAppearance on the line after mount without waiting.
Events
| Event | Payload | When |
|---|---|---|
ready | {payInId, displayId, method, amount, currency, locale} | The frame is up and the channel is open. |
resize | {height} | The content height changed. Already applied when height: 'auto'. |
claim | {payInId, at, withUtr} | The payer pressed I have paid, with or without a UTR. |
claim | {payInId, at, withUtr} | The payer pressed I have paid. There is no reference to send with it, so withUtr is always false. |
success | {payInId, displayId, completedAt} | The payment was confirmed. A UI hint only; see the warning above. |
expired | {payInId} | The link expired with nothing left to verify. |
failed | {payInId} | The payment was not confirmed. |
cancel | {payInId} | The payer asked to leave (Cancel and return to store, Return to store, Start a new payment). The frame never navigates: the routing is yours. |
intent | {uri} | Fallback: the UPI app did not open, so the loader navigates the top window itself. |
intent | {uri} | Fallback: the Kakao Pay app did not open, so the loader navigates the top window itself. |
download | {filename, dataUri} | Fallback: the frame could not save the QR, so the loader saves it. |
focus | {} | The payer focused the UTR field. Scroll it into view if your layout needs it. |
error | {code, message} | From the loader: NESTED_FRAME, NO_RESPONSE, HANDSHAKE_FAILED, SEND_FAILED, NO_MESSAGE_CHANNEL. Forwarded from the frame: INVALID_LINK, THEME_UNKNOWN_KEY. See Errors. |
Allowed embedding domains
The page refuses to be framed by an origin you have not registered. The list is an account setting, not a per-transaction field, so it is not part of this HMAC contract. You write it from your merchant dashboard session (JWT):
PUT /api/merchant/checkout-settings/origins
{ "origins": ["https://shop.example", "https://*.shop.example"] }
The call replaces the whole list; an empty array turns embedding off. GET /api/merchant/checkout-settings returns what is stored, together with the preset names and every appearance variable with its Atlas default, so you never have to hard-code that vocabulary. The merchant dashboard in production has no screen for this list; use the endpoint above or ask us to set the domains for you.
| Rule | Detail |
|---|---|
| Scheme | https:// only (http://localhost is accepted on a Development stand). |
| Shape | Scheme and host, optionally :port. No path, no query, nothing after the host or port: https://shop.example, https://checkout.shop.example:8443. |
| Wildcards | https://*.shop.example matches exactly one label deep and does not cover the bare apex, so register both. Saving a wildcard without its bare domain still succeeds but returns the warning ORIGIN_WILDCARD_WITHOUT_APEX:<origin>. Do not ignore it. |
| Maximum | 10 entries. Order is kept as you typed it; duplicates are dropped. |
error {code:'NO_RESPONSE'} after about 8 seconds. The notice exists because a frame the browser blocks cannot be diagnosed from outside: Chrome fires load as if nothing happened, Firefox fires nothing, and no securitypolicyviolation reaches your page.
Appearance and branding
One object, schema 2, describes the whole look. Your account stores this object, the hosted page renders with it, and you may override it per page with mount({ appearance }).
| Member | Description |
|---|---|
version | Schema version. 1 and 2 are both understood; a higher number is applied best-effort with a warning. The number declares what you knew about, not what we honour: every schema-2 member below also works on a version: 1 document, so nothing you already saved needs editing. |
theme | Preset base: atlas (default indigo) | light (neutral grey, near-black accent) | dark | minimal (no shadow, no ground, tighter radii) | auto (new in schema 2: light, with the dark palette when the visitor's OS asks for it). |
variables | The 37 public design tokens listed below. |
rules | Targeted exceptions: allow-listed selector → allow-listed properties. |
branding | { logo: 'atlas' | 'merchant' | 'none', wordmark: bool, poweredBy: bool, logoUrl, logoHeight, logoAlt }. New in schema 2: merchant now works. Give it a logoUrl and the page shows your mark instead of ours. Without a URL it still renders the Atlas mark, so the card is never unbranded by accident. logoHeight is 16–48 (default 24); logoAlt is plain text of at most 60 characters. poweredBy is independent of all of these. |
background | New in schema 2. An image, a gradient, or a tint over an image; see Background. |
display | New in schema 2. Shape switches: you pick a word and the page draws it; see Display. |
texts | New in schema 2. Your own wording for fifteen strings, per language; see Texts. |
variablesDark | New in schema 2. The same variable vocabulary, applied under prefers-color-scheme: dark. With theme: 'auto' it layers on top of the dark preset, so an empty object is already a complete dark mode. |
layout | 'card' (default) | 'compact': tighter rhythm, no identity row. |
locale | 'auto' | 'en' | 'hi'. Any value but auto outranks the locale stored on the pay-in, but never an explicit ?lang=. |
locale | 'auto' | 'en' | 'ko'. Any value but auto outranks the locale stored on the pay-in, but never an explicit ?lang=. |
languageSwitcher | Show the in-frame language pill. Default off in an embed, on for the hosted page. |
Background
Layers paint in this order: overlay over gradient over image. A tint therefore sits on top of the photo, which keeps text readable on it. A background that would paint nothing (a size with no image) is dropped rather than half-applied.
| Member | Rule |
|---|---|
image | Absolute https URL, at most 2048 characters, on a public domain (no IP literal, no localhost, no credentials in the URL), without ", \, (, ), <, >, ' or spaces. A URL outside these rules is refused (THEME_INVALID_URL), never escaped into something else. |
size | cover (default) | contain | auto. |
position | center (default) | top | bottom | left | right. |
repeat | no-repeat (default) | repeat. |
overlay | A hex colour, #RRGGBBAA included, laid over the image. |
gradient | { from: hex, to: hex, angle: 0–360 }. Both stops or neither. |
target | 'page' (default, the ground behind the card) | 'card'. In an embed the ground is transparent so your own page shows through, which leaves 'card' as the only target with a visible effect there. |
img-src for your pages only; every other merchant's policy stays untouched.
Display
Shape, not colour. Each switch picks between looks the page already knows how to draw, so unlike a free-form colour none of them can produce an unreadable screen. They are emitted as data-* attributes on the page root.
| Name | Values | Notes |
|---|---|---|
buttonStyle | gradient | solid | outline | The CTA's fill. |
buttonTextTransform | none | uppercase | |
buttonWeight | 600 | 700 | 800 | Default 700. |
buttonShadow | on | off | |
stepsStyle | dots | hidden | The Pay / Confirm / Done strip. |
timerStyle | capsule | plain | hidden | hidden hides the capsule, not the countdown: the remaining time still reaches the payer and the screen reader. A theme cannot take the deadline away. |
headerAlign | center | left | |
qrCorner | square | rounded | Default rounded. |
borderWidth | 0 | 1 | 2 | Default 1. |
groundPattern | none | grid | Hosted page only; an embed has no ground of its own. |
cardBorder | on | off | Default off. |
Texts
texts is { <locale>: { <key>: string } }, keyed by the languages the page is served in: en and hien and ko. Values are plain text of at most 120 characters with no < or >. {placeholders} in braces are kept and still filled in at run time, so you can rewrite the sentence around them.
The fifteen overridable keys: cta.paid, cta.confirmWithUtr, cta.continueWithoutUtr, cta.back, cta.returnNow, cta.openUpiApp, state.success, state.successHint, state.successHintEmbed, state.waiting, state.waitingHint, state.waitingHint2, hint.exact, head.amountLabel, foot.secure.
THEME_FORBIDDEN_TEXT rather than silently dropped, so you get an answer instead of a mystery.
Variables: the 37 public names
The public names are the contract. The internal CSS custom properties are not, so we can re-lay-out the page without breaking your theme. Defaults below are the atlas preset.
| Name | Atlas default | Where it shows |
|---|---|---|
| Base | ||
colorBackground | #F8FAFC | Page / embed ground |
colorSurface | #FFFFFF | The card |
colorSurfaceMuted | #F1F5F9 | Tiles, language pill |
colorSurfaceHover | #E2E8F0 | Hover states |
colorBorder | #E2E8F0 | Dividers, field borders |
colorBorderStrong | #CBD5E1 | Chips, disabled |
| Primary | ||
colorPrimary | #4F46E5 | CTA, timer ring, focus, active step |
colorPrimaryHover | #4338CA | CTA hover. Derived from colorPrimary when not set |
colorPrimaryAlt | #6366F1 | Second stop of the CTA gradient. Derived from colorPrimary when not set |
colorPrimarySoft | #EEF2FF | Tints, active-step halo. Derived from colorPrimary when not set |
colorPrimaryBorder | #C7D2FE | Accent block borders. Derived from colorPrimary when not set |
colorOnPrimary | #FFFFFF | Text on the CTA |
| Text | ||
colorText | #0F172A | Body text, the amount |
colorTextSecondary | #64748B | Captions |
colorTextMuted | #94A3B8 | Decorative glyphs |
colorLabel | #64748B | Uppercase section labels |
| Status | ||
colorSuccess / colorSuccessBg / colorSuccessBorder | #047857 / #ECFDF5 / #A7F3D0 | Success |
colorWarning / colorWarningBg / colorWarningBorder | #B45309 / #FFFBEB / #FDE68A | Grace period, timer thresholds |
colorDanger / colorDangerBg / colorDangerBorder | #B91C1C / #FEF2F2 / #FECACA | Expired / failed |
colorInfo / colorInfoBg / colorInfoBorder | #1D4ED8 / #EFF6FF / #BFDBFE | Hints (for example the IMPS note) |
colorInfo / colorInfoBg / colorInfoBorder | #1D4ED8 / #EFF6FF / #BFDBFE | Hints (for example the bank-transfer note) |
| Shape | ||
borderRadiusSm | 6px | Small controls |
borderRadiusMd | 10px | Fields |
borderRadiusLg | 14px | Tiles |
borderRadiusXl | 18px | The card |
borderRadiusFull | 9999px | Pill buttons, chips |
shadowCard | sm | Card shadow. Enum none | sm | md | lg, never raw CSS |
| Type | ||
fontFamilyDisplay | DM Sans | Amounts, timer, buttons |
fontFamilyBody | DM Sans | Body text (Hindi falls back to Noto Sans Devanagari) |
fontFamilyBody | DM Sans | Body text (a ko page falls back to Apple SD Gothic Neo / Malgun Gothic / Noto Sans KR) |
fontFamilyMono | JetBrains Mono | VPA, UTR, account number |
fontFamilyMono | JetBrains Mono | Account number |
colorPrimary alone and the four variables after it are computed from it instead of staying on the preset's hue. colorPrimaryHover and colorPrimaryAlt are the primary pulled 14% / 22% towards black when it is light (WCAG relative luminance ≥ 0.40), or 14% / 16% towards white when it is dark. colorPrimarySoft / colorPrimaryBorder are the primary mixed 12% / 35% into the effective colorSurface. Set any of the four explicitly and your value wins: they stay ordinary configurable variables, and a theme that never mentions colorPrimary derives nothing at all.
Rules
Selectors: .Card, .Header, .Amount, .Timer, .Button, .ButtonSecondary, .ButtonLink, .Input, .Label, .Field, .Chip, .Alert, .QrBox, .Footer, .Step. New in schema 2: .Steps (the whole step strip, where .Step is one item), .Copy (the copy buttons), .Notice (the same element as .Alert, under a neutral name), .Result (the terminal success / expired / failed panel), .Brand (the brand row) and .Ground (the page ground itself). Each selector may carry one modifier: :hover, :focus-visible, :disabled or --invalid.
Allowed properties: color, backgroundColor, border, borderColor, borderWidth, borderStyle, borderRadius, boxShadow (enum), fontWeight (enum), letterSpacing, textTransform (enum), padding, outlineColor.
Refused with an error: display, visibility, opacity, position, transform, zIndex, pointerEvents, content, background / backgroundImage, clipPath, filter, width / height / minWidth / maxWidth / minHeight / maxHeight, fontSize, overflow, animation, transition, inset / top / right / bottom / left, margin, float, order, mixBlendMode, backdropFilter, mask, appearance. Each of them can hide, move or overwrite something the payer must see: the amount, the recipient's requisites, the timer, the status or the cancel button.
backgroundImage stays refused inside rules, where it would be a free-form url(…) on any element. The schema-2 background member is the bounded way to put a picture on the page, and it can reach only the ground or the card.
.Alert { color | backgroundColor | borderColor } is inert: the info / warning / danger strips keep their own colours, and .Alert styles their shell (radius, padding, border width, weight). .Timer { color } and .Step { color } apply in the neutral state only and are ignored once the countdown turns amber or red, or a step goes ok / error. A payer has to be able to tell "do not pay again" from a hint.
Validation
| Value | Rule |
|---|---|
| Colour | Hex only: #RGB, #RRGGBB, #RRGGBBAA. rgb(), hsl(), var(), color-mix(), url() and named colours are rejected (Stripe's Appearance API draws the same line). |
| Radius / width / padding | 0–48px (the px suffix is optional; borderRadiusFull also accepts 9999px). |
| Shadow | Enum none | sm | md | lg. A raw box-shadow is not accepted. |
fontWeight | Enum 400 | 500 | 600 | 700 | 800. |
textTransform | Enum none | uppercase | capitalize. |
| Font family | A name from the allow-list, never a CSS stack and never a URL. Self-hosted (no third-party origin, nothing extra before first paint): DM Sans, Inter, Noto Sans Devanagari, JetBrains Mono, system-ui, ui-monospace. Google (schema 2): Manrope, Plus Jakarta Sans, Poppins, IBM Plex Sans, Space Grotesk, Outfit, Nunito, Roboto, Open Sans, Lato, Montserrat, Sora, Noto Sans KR. |
URL (background.image, branding.logoUrl) | https only, at most 2048 characters, on a public domain (no IP literal, no localhost, no credentials), free of ", \, (, ), <, >, ' and whitespace. Refused as THEME_INVALID_URL. |
| Text override | Plain text, at most 120 characters, no < or >, no control characters. A key outside the fifteen is THEME_FORBIDDEN_TEXT. |
| Size limits | ≤ 64 characters per value (URLs 2048, text overrides 120), ≤ 16 KB for the whole theme, ≤ 40 entries in rules, ≤ 60 in texts. The theme limit was 4 KB before schema 2; two URLs, a dark palette and 45 text overrides do not fit in 4 KB. |
| Unknown key | Ignored, reported as a warning on save and as error {code:'THEME_UNKNOWN_KEY'} in the browser. |
https://fonts.googleapis.com in style-src, https://fonts.gstatic.com in font-src, and one render-blocking request before first paint, and your payer's IP address reaches Google. A self-hosted family avoids all of that: it is the faster and the more private choice, which is why the list is split rather than merged. The policy is widened only for pages that render a theme using such a family; every other merchant's headers stay byte-identical to what they always were. The same applies to img-src and your background and logo origins.
Contrast is checked on save. Text/background pairs (colorText, colorTextSecondary, colorLabel against colorSurface; colorOnPrimary against colorPrimary; each status colour against its own background) are measured against WCAG AA 4.5:1. Below that you get THEME_CONTRAST_LOW as a warning and the theme still saves. Below 3:1 on the main text pair the result is THEME_CONTRAST_UNREADABLE and the save is refused. Other refusal codes you may see: THEME_INVALID_JSON, THEME_INVALID_VALUE, THEME_VALUE_TOO_LONG, THEME_FORBIDDEN_PROPERTY, THEME_TOO_MANY_RULES, THEME_TOO_LARGE, THEME_UNKNOWN_VERSION.
Where the theme lives
The theme saved on your merchant account is the default for both surfaces, the hosted page and the embed, and it is applied server-side before the first paint. Save it from your dashboard session with PUT /api/merchant/checkout-settings/theme (body: the appearance object, or null to reset). GET /api/merchant/checkout-settings returns it together with the preset names, every variable with its Atlas default, and the schema-2 vocabularies (the display switches with their values, the overridable text keys, the background enums), so your code never re-types a list of ours.
Errors refuse the save and list every problem at once. Warnings (unknown keys, weak contrast, a logo URL that cannot render) come back on a successful save. In production, save the theme with this endpoint. The visual editor Pay Page appearance, with a live preview that is a real embedded checkout driven through the documented setAppearance(), exists only in the sandbox merchant dashboard and is not part of the production build.
mount({ appearance }) is an override, not a replacement. The merge order is preset → your saved theme → what you pass to mount(), key by key, and setAppearance() updates only the parts each call names, so a branding-only call keeps the palette an earlier call applied. variables, rules, display, texts and variablesDark merge by key (rules and texts two levels deep). branding, background, layout and locale are single decisions and are replaced whole.
Security
embedUrl exists only after POST /pay-in signed with your HMAC key from your server. Atlas mints and verifies its token for that one order, and GET /embed/{token} answers with frame-ancestors limited to the origins you registered; anywhere else the browser refuses to paint it. There is deliberately no client-side "publishable key": the order is created server-to-server, and the success event stays a UI hint until the signed pay_in.completed webhook (product decision, 2026-09-11).
connect-src in particular is not needed, because the document inside the frame makes the network calls under its own policy.
script-src https://api.atlas-online.io; frame-src https://api.atlas-online.io;
- Do not put
integrityon/embed/v1/atlas.js. That URL is mutable on purpose (Cache-Control: max-age=300) so a fix reaches live checkouts in minutes, and an SRI hash cannot match a file that changes. If your policy mandates SRI, pin the immutable build instead: read/embed/v1/manifest.json({"version","file","integrity"}), load/embed/v1/atlas.<hash>.jswith thatintegrityvalue andcrossorigin="anonymous", and follow our deprecation window. - Do not restrict
clipboard-writefor our frame inPermissions-Policy. The Copy UPI ID and Copy amount buttons need it; without it they fall back to asking the payer to select the text by hand. - Do not restrict
clipboard-writefor our frame inPermissions-Policy. The Copy account number and Copy amount buttons need it; without it they fall back to asking the payer to select the text by hand. - Do not nest the frame inside another iframe or an in-app WebView. The
upi://deep link dies silently there: the payer taps Pay in UPI app and nothing happens, with no error anywhere. The loader detects it and raiseserror {code:'NESTED_FRAME'}. Treat that as "redirect instead", not as a warning to suppress. - Do not nest the frame inside another iframe or an in-app WebView. The hand-off to the
kakaopay.comlink dies silently there: the payer taps Open Kakao Pay and nothing happens, with no error anywhere. The loader detects it and raiseserror {code:'NESTED_FRAME'}. Treat that as "redirect instead", not as a warning to suppress.
Layout and mobile
The iframe is display:block; width:100% and follows its content. Do not put it in a fixed-height container with overflow:hidden: the card grows and shrinks as the payment moves through its states, and it will be clipped. If you must pin the height, pass height: <number> and accept the internal scrollbar.
On phones, consider redirecting to the hosted page instead, because fewer layers then sit between the payer's tap and their banking app. Atlas.shouldRedirect() is a hint (narrow viewport with a coarse pointer, or an in-app WebView user agent); the decision stays yours.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
A 403 notice inside the frame names a domain; the loader raises error {code:'NO_RESPONSE'} after about 8 seconds. | The page is framed by an origin you have not registered, often the bare apex when only the wildcard is registered. | Add the origin with PUT /api/merchant/checkout-settings/origins. Register both the apex and the wildcard. |
The payer taps Pay in UPI app and nothing happens; the loader raises error {code:'NESTED_FRAME'}. | The frame sits inside another iframe or an in-app WebView. | Redirect to checkout.url instead. |
The payer taps Open Kakao Pay and nothing happens; the loader raises error {code:'NESTED_FRAME'}. | The frame sits inside another iframe or an in-app WebView. | Redirect to checkout.url instead. |
| On a dark page, Chromium draws a white rim around the frame. | Your page declares a dark color-scheme and the frame inherits it, while the payment page inside the frame is light. Chromium then paints an opaque white background behind the frame. | Set a light colour scheme on the mount container: #atlas-pay { color-scheme: light; }. |
| The card is clipped. | A fixed-height container with overflow:hidden. | Let the iframe follow its content, or pass height: <number> and accept the internal scrollbar. |
| The copy buttons ask the payer to select the text by hand. | Permissions-Policy restricts clipboard-write for the frame. | Do not restrict clipboard-write for our frame. |
An SRI check stops matching /embed/v1/atlas.js. | That file is mutable (max-age=300). | Pin the hashed build from /embed/v1/manifest.json. |
The browser reports error {code:'THEME_UNKNOWN_KEY'}. | The appearance object contains a key the page does not know; it is ignored. | Check the key against the names in GET /api/merchant/checkout-settings. |
Errors
Creating the pay-in can fail with 400 CHECKOUT_NOT_SUPPORTED or, since v2.18, 400 CLIENT_BLOCKED; see Hosted checkout errors. Everything else surfaces in the browser through the error event, or as a response to a settings save.
CLIENT_BLOCKED means there is nothing to mount. The response has no checkout object, so there is no checkout.embedUrl and you must not call Atlas.mount. Show the payer a neutral message in the space the widget would take, for example “Payment is unavailable. Please contact the shop's support.”, and do not retry the create. See Blocked clients.| Code | Where | Effect |
|---|---|---|
NESTED_FRAME | error event, from the loader | The frame is nested inside another iframe or an in-app WebView. Redirect instead. |
NO_RESPONSE | error event, from the loader | Raised after about 8 seconds, for example when an unregistered origin frames the page. |
HANDSHAKE_FAILED | error event, from the loader | The first message to the loaded frame could not be posted, so the frame never connects. message carries the browser's error. Redirect to checkout.url instead. |
SEND_FAILED | error event, from the loader | A call to the frame (setAppearance(), setLocale(), refresh(), focus()) could not be posted, for example because its argument holds a value the browser cannot copy, such as a function. message carries the browser's error. |
NO_MESSAGE_CHANNEL | error event, from the loader | The browser has no MessageChannel, so the frame cannot be embedded. Redirect to checkout.url instead. |
INVALID_LINK | error event, forwarded from the frame | The page token is invalid or has expired: "This payment link is invalid or has expired". Create a new pay-in for a new link. |
THEME_UNKNOWN_KEY | error event, forwarded from the frame | The unknown key is ignored. A save reports it as a warning. |
THEME_CONTRAST_LOW | Theme save, warning | A text/background pair is below WCAG AA 4.5:1. The theme still saves. |
THEME_CONTRAST_UNREADABLE | Theme save, refusal | The main text pair is below 3:1. The save is refused. |
THEME_INVALID_URL | Theme save, refusal | A background.image or branding.logoUrl breaks the URL rules. |
THEME_FORBIDDEN_TEXT | Theme save, refusal | A texts key outside the fifteen. |
THEME_INVALID_JSON, THEME_INVALID_VALUE, THEME_VALUE_TOO_LONG, THEME_FORBIDDEN_PROPERTY, THEME_TOO_MANY_RULES, THEME_TOO_LARGE, THEME_UNKNOWN_VERSION | Theme save, refusal | The save is refused; the response lists every problem. |
ORIGIN_WILDCARD_WITHOUT_APEX:<origin> | Origins save, warning | A wildcard was saved without its bare domain. The save succeeds; add the apex. |
Try it on the sandbox
A full demo store, Atlas Arcade, is published on our sandbox landing at https://sandbox.atlas-online.io/demo/. It embeds this exact component on a page that looks nothing like Atlas, on an origin that differs from the API's. It walks the whole flow (choose upi / upi_intent / impskor_bank / kakao_pay, pay, watch Atlas confirm the payment, see the wallet credit come from the database rather than from the browser event) and carries a live theme switcher that prints its output as a ready-to-copy Atlas.mount() snippet.
In your sandbox merchant dashboard, Pay-Ins → Test Pay Page creates an ordinary sandbox pay-in whose embedUrl you can mount straight into your staging checkout. The production dashboard does not have this page.
Browser-facing embed endpoints
These serve the loader and the framed page. They are not HMAC-authenticated and not part of the merchant API; your backend never calls them.
| Method | Path | Purpose |
|---|---|---|
| GET | /embed/{token} | The payment page in embeddable form: the value of checkout.embedUrl. Served with a frame-ancestors built from your registered domains; an unregistered origin gets the readable 403 notice instead. |
| GET | /embed/v1/atlas.js | The loader. Mutable, max-age=300. Use this one. |
| GET | /embed/v1/atlas.{hash}.js | The same loader, immutable, for SRI users. |
| GET | /embed/v1/manifest.json | { version, file, integrity }: which hashed build is current and its SHA-384. |
Breaking changes get a new path (/embed/v2/), and /v1/ then freezes. Every message on the channel carries a version, and both sides ignore a version they do not know, so an old loader and a new frame degrade to silence rather than to garbage. Atlas.version is the loader's own version string.
{
"amount": 3049,
"currency": "INR",
"geo": 3,
"expiresIn": 900,
"merchantOrderId": "order-in-embed-12345",
"clientReference": "customer-7781",
"paymentMethod": "upi",
"checkout": {
"returnUrl": "https://shop.example/pay/done?o={PAY_IN_ID}",
"cancelUrl": "https://shop.example/cart",
"locale": "en"
}
}{
"amount": 3049,
"currency": "INR",
"geo": 3,
"expiresIn": 900,
"merchantOrderId": "order-in-embed-12347",
"clientReference": "customer-7781",
"paymentMethod": "upi_intent",
"checkout": {
"returnUrl": "https://shop.example/pay/done?o={PAY_IN_ID}",
"cancelUrl": "https://shop.example/cart",
"locale": "en"
}
}{
"amount": 5000,
"currency": "INR",
"geo": 3,
"expiresIn": 900,
"merchantOrderId": "order-in-embed-12346",
"clientReference": "customer-7781",
"paymentMethod": "imps",
"checkout": {
"returnUrl": "https://shop.example/pay/done?o={PAY_IN_ID}",
"cancelUrl": "https://shop.example/cart",
"locale": "en"
}
}{
"amount": 50000,
"currency": "KRW",
"geo": 4,
"expiresIn": 900,
"merchantOrderId": "order-kr-embed-12345",
"clientReference": "customer-7781",
"paymentMethod": "kor_bank",
"checkout": {
"returnUrl": "https://shop.example/pay/done?o={PAY_IN_ID}",
"cancelUrl": "https://shop.example/cart",
"locale": "en"
}
}{
"amount": 50000,
"currency": "KRW",
"geo": 4,
"expiresIn": 900,
"merchantOrderId": "order-kr-embed-12346",
"clientReference": "customer-7781",
"paymentMethod": "kakao_pay",
"checkout": {
"returnUrl": "https://shop.example/pay/done?o={PAY_IN_ID}",
"cancelUrl": "https://shop.example/cart",
"locale": "en"
}
}checkout block{
"checkout": {
"url": "https://api.atlas-online.io/pay/AbC9fQ2...",
"expiresAt": "2026-09-10T09:15:00.000Z",
"locale": "en",
"embedUrl": "https://api.atlas-online.io/embed/AbC9fQ2..."
}
}<div id="atlas-pay"></div> <script src="https://api.atlas-online.io/embed/v1/atlas.js"></script> <script> var pay = Atlas.mount('#atlas-pay', { url: checkoutEmbedUrl, // checkout.embedUrl from POST /api/merchant/pay-in appearance: { version: 2, theme: 'auto', // light, dark when the visitor asks variables: { colorPrimary: '#0F62FE', borderRadiusXl: '12px' }, variablesDark: { colorSurface: '#101418', colorText: '#E6EAF2' }, background: { image: 'https://cdn.example.com/bg.jpg', overlay: '#0F172A99', target: 'card' }, display: { buttonStyle: 'outline', timerStyle: 'plain' }, texts: { en: { 'cta.paid': 'I have paid' } } }, branding: { logo: 'none', poweredBy: false }, layout: 'card', locale: 'en', height: 'auto' }); pay.on('success', function (e) { showThankYou(e.displayId); }); // UI hint only pay.on('cancel', function () { location.href = '/cart'; }); pay.on('expired', function () { offerNewOrder(); }); pay.on('error', function (e) { console.warn('atlas', e.code, e.message); }); </script>
pay.setAppearance({
version: 1,
theme: 'minimal',
variables: {
colorPrimary: '#0F62FE',
colorSurface: '#FFFFFF',
borderRadiusXl: '12px',
fontFamilyBody: 'Inter'
},
rules: {
'.Button': { borderRadius: '8px', fontWeight: '600' },
'.Card': { boxShadow: 'none', border: '1px solid #E5E7EB' },
'.Input:focus-visible': { outlineColor: '#0F62FE' }
},
branding: { logo: 'none', wordmark: false, poweredBy: false },
layout: 'compact'
});
pay.setLocale('hi');
pay.setLocale('ko');
pay.refresh(); // e.g. after returning from a UPI app
pay.refresh(); // e.g. after returning from the payer's bank app
pay.unmount(); // remove the frame and every handler
// GET https://api.atlas-online.io/embed/v1/manifest.json { "version": "1", "file": "atlas.9f2c….js", "integrity": "sha384-…" } <script src="https://api.atlas-online.io/embed/v1/atlas.9f2c….js" integrity="sha384-…" crossorigin="anonymous"></script>
Create a pay-out
/api/merchant/pay-outSends an outbound payment from you to a recipient's account. You submit the destination details and the amount; the platform executes and validates the transfer asynchronously.
Call it from your server whenever you pay out to one of your end customers. Argentina has a single pay-out rail, so the recipient object never varies: see the recipient fields below.Venezuela has a single pay-out rail, so the recipient object never varies: see the recipient fields below.In India the recipient fields depend on the rail: IMPS is the default, and paymentMethod: "upi" pays a VPA instead. See the recipient fields below.South Korea has a single pay-out rail, so the recipient object never varies: see the recipient fields below.
Funding in USDT
Pay-outs are funded in USDT from your pay-out balance (payOutBalance in Get balance). When the pay-out balance covers a pay-out, the platform reserves the pay-out amount in USDT plus the pay-out commission. The reserved sum shows in reservedBalance and stays locked while the pay-out is in flight. The response reports the pay-out amount alone in amountUsdt, converted at the rate at creation time, without the commission.
Move USDT from your pay-in balance to the pay-out balance with Create a transfer, or in the merchant dashboard. If the pay-out balance is too low for a pay-out, the request is still accepted. It returns 201 Created like any other, reserves nothing and is held with status AwaitingFunds for up to 12 hours from creation. A transfer that covers it releases it, oldest first. The 201 body has no status field and no webhook is sent when a pay-out is held or released, so read the status with Get pay-out status.
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
recipientName | string | Required | Recipient full name as registered in the bank. |
amount | decimal | Required | Payment amount, greater than zero. Must be a whole number of INR. Fractional amounts are rejected, never rounded. Must be a whole number of KRW. Fractional amounts are rejected, never rounded. |
currency | string | Required | ISO 4217 code (ARSVESINRKRW). |
geo | integer | string | Required | Region code or enum name. |
expiresIn | integer | Required | Lifetime of the pay-out in seconds. |
merchantOrderId | string | Required | Idempotency key unique to the merchant. |
clientReference | string | Required | Your stable identifier for the recipient end customer. Pattern: [A-Za-z0-9._:-], max 64 chars. |
paymentMethod | string | Not used in Argentina | Every Argentina pay-out goes to a CBU, so leave the field out. A value you send is ignored. |
paymentMethod | string | Optional | Explicit pay-out rail. Ignored for Venezuela pay-outs. |
paymentMethod | string | Optional | Explicit pay-out rail. Two rails since v2.6: omit the field or send imps for the IMPS bank transfer, or send upi to pay a VPA (then recipientUpiVpa is required). Anything else returns VALIDATION_ERROR: "paymentMethod '<x>' is not available for India pay-outs; use imps or upi". |
paymentMethod | string | Optional | Explicit pay-out rail. kor_bank is the only supported South Korea pay-out rail: omit the field (it defaults to kor_bank) or send kor_bank. kakao_pay is rejected with VALIDATION_ERROR. |
Recipient fields
Send only the fields that belong to the rail. A field that does not belong is rejected with 400 VALIDATION_ERROR.
CBU transfer: send recipientCbu only, no other recipient field.
| Name | Type | Required | Description |
|---|---|---|---|
recipientCbu | string | Required | Destination CBU. 22-digit, with the standard CBU checksum. |
Pago Móvil / bank transfer: send recipientPhone + recipientCedula + recipientBank + recipientAccountNumber. Do not send any other recipient field.
| Name | Type | Required | Description |
|---|---|---|---|
recipientPhone | string | Required | Phone number associated with the destination wallet (e.g. Pago Móvil). |
recipientCedula | string | Required | Recipient's national ID (cédula). |
recipientBank | string | Required | Destination bank or payment provider (e.g. Banesco, Pago Móvil, Zinli). Any non-empty value is accepted; it is not checked against List banks, which may return an empty list. |
recipientAccountNumber | string | Required | Bank account number (número de cuenta), 5-20 digits. Hyphens and spaces are accepted and stored bare. |
IMPS rail (default)
imps (default), used when paymentMethod is omitted or imps: send recipientAccountNumber + recipientBank + recipientIfsc. Do not send recipientUpiVpa, nor any other recipient field.
| Name | Type | Required | Description |
|---|---|---|---|
recipientAccountNumber | string | Required on IMPS | IMPS beneficiary account number, 5-20 digits. Hyphens and spaces are accepted and stored bare. Do not send on the UPI rail. |
recipientBank | string | Required on IMPS | Beneficiary bank name (e.g. HDFC Bank). Do not send on the UPI rail. |
recipientIfsc | string | Required on IMPS | IFSC branch code, 11 chars (AAAA0XXXXXX, e.g. SBIN0003764). Do not send on the UPI rail. |
UPI rail
upi (v2.6): send recipientUpiVpa + paymentMethod: "upi". The VPA is the whole recipient object. Do not send recipientAccountNumber, recipientBank or recipientIfsc, nor any other recipient field.
| Name | Type | Required | Description |
|---|---|---|---|
recipientUpiVpa | string | Required on UPI | New in v2.6. Beneficiary UPI ID / VPA in handle@psp form (e.g. ravi.kumar@ybl), stored lower-cased. Do not send on the IMPS rail ("recipientUpiVpa is only valid for paymentMethod upi"). A malformed value returns "recipientUpiVpa has invalid UPI ID format (expected handle@psp)". |
kor_bank: send recipientAccountNumber + recipientBank. The kor_bank bank transfer is the only South Korea pay-out rail; paymentMethod: "kakao_pay" is rejected. Do not send any other recipient field.
| Name | Type | Required | Description |
|---|---|---|---|
recipientAccountNumber | string | Required | Bank account number, 5-20 digits. Hyphens and spaces are accepted and stored bare. |
recipientBank | string | Required | Destination bank (e.g. KB Kookmin Bank). Codes and names come from List banks. |
Response fields 201 Created
| Field | Type | Description |
|---|---|---|
payOutId | UUID | Platform identifier. Use it with Get pay-out status. |
displayId | string | Human-readable identifier (PO00000001). |
amountFiat | decimal | Amount in the declared currency. |
amountUsdt | decimal | Pay-out amount in USDT at the creation-time rate, without the pay-out commission. |
currency | string | Currency code. |
expiresAt | datetime | Expiration timestamp. |
recipientUpiVpa | string | New in v2.6. The normalised (lower-cased) beneficiary VPA we stored. Present only on a UPI pay-out. The key is omitted entirely on the IMPS rail, so those responses are byte-identical to v2.5. |
Statuses
The create response carries no status. A funded pay-out starts as Pending; one submitted before the pay-out balance is funded starts as AwaitingFunds. Read the current status with Get pay-out status and wait for the pay_out.completed, pay_out.failed or pay_out.expired webhook before you treat the pay-out as settled. All values are listed in the Status reference.
Errors
| HTTP | Error | Message |
|---|---|---|
| 400 | VALIDATION_ERROR | Validation failure (body, currency, geo, recipient fields, merchant status). |
| 400 | AMOUNT_BELOW_MINIMUM | Amount is below the merchant's configured minimum. |
| 400 | AMOUNT_ABOVE_MAXIMUM | Amount exceeds the merchant's configured maximum. |
| 400 | IDEMPOTENCY_KEY_MISMATCH | Accounts with strict idempotency only. The merchantOrderId belongs to an earlier pay-out that has not failed, and the new request differs in amount, currency, geo, recipientName or a recipient field. Without strict idempotency the original pay-out comes back with 201. |
| 400 | SPREAD_MISCONFIGURED | Internal rate configuration error. Contact Atlas support. |
| 409 | CONCURRENT_UPDATE | The request collided with a concurrent request on the same data and was not completed, or waited about 5 seconds for other pay-out creates on your account to finish. Retry it with the same merchantOrderId: it never creates a duplicate. |
| 401 | See Authentication | Authentication failure. |
UPI rail validation messages (v2.6), each returned as 400 VALIDATION_ERROR:
"recipientUpiVpa is required for India UPI pay-outs""recipientUpiVpa has invalid UPI ID format (expected handle@psp)""recipientAccountNumber, recipientBank and recipientIfsc are not valid for India UPI pay-outs; send recipientUpiVpa only""recipientUpiVpa is only valid for paymentMethod upi""recipientUpiVpa is only valid for India""paymentMethod '<x>' is not available for India pay-outs; use imps or upi"
A pay-out balance that is short of USDT is not an error on this endpoint: the pay-out is held as AwaitingFunds (see Funding in USDT). Every error code and the retry rules are in the Error reference.
Notes
How a UPI pay-out settles. The money is sent from a UPI account in the Atlas network to the VPA, and the proof of the transfer is uploaded. You are notified by the same pay_out.completed webhook, which echoes recipientUpiVpa.
merchantOrderId is unique per merchant. After a timeout, retry with the same merchantOrderId and the same body. To find out first whether the pay-out was created, use Get pay-out status by merchantOrderId: 200 means it exists, 404 means it was not created.Product overview: Pay-outs, CVU/CBU, Argentina, Pago Móvil, Venezuela, IMPS, UPI, India, Korean bank transfer, South Korea.
{
"recipientCbu": "0000177501057000189625",
"recipientName": "Maria Garcia Lopez",
"amount": 5000.00,
"currency": "ARS",
"geo": 1,
"expiresIn": 1800,
"merchantOrderId": "payout-67890",
"clientReference": "customer-7781"
}{
"recipientPhone": "+584121234567",
"recipientCedula": "V-12345678",
"recipientBank": "Banesco",
"recipientAccountNumber": "01020123456789012345",
"recipientName": "Carlos Rodríguez",
"amount": 1500.00,
"currency": "VES",
"geo": 2,
"expiresIn": 1800,
"merchantOrderId": "payout-ve-12345",
"clientReference": "customer-7781"
}{
"recipientAccountNumber": "40145477128",
"recipientBank": "HDFC Bank",
"recipientIfsc": "HDFC0001234",
"recipientName": "Ravi Kumar",
"amount": 10000,
"currency": "INR",
"geo": 3,
"expiresIn": 1800,
"merchantOrderId": "payout-in-12345",
"clientReference": "customer-7781"
}{
"recipientUpiVpa": "ravi.kumar@ybl",
"recipientName": "Ravi Kumar",
"amount": 10000,
"currency": "INR",
"geo": 3,
"expiresIn": 1800,
"merchantOrderId": "payout-in-upi-12345",
"clientReference": "customer-7781",
"paymentMethod": "upi"
}{
"recipientAccountNumber": "110-123-456789",
"recipientBank": "KB Kookmin Bank",
"recipientName": "Kim Minseo",
"amount": 150000,
"currency": "KRW",
"geo": 4,
"expiresIn": 1800,
"merchantOrderId": "payout-kr-12345",
"clientReference": "customer-7781"
}{
"payOutId": "660e8400-e29b-41d4-a716-446655440111",
"displayId": "PO00000001",
"amountFiat": 5000.00,
"amountUsdt": 4.85,
"currency": "ARS",
"expiresAt": "2026-04-14T16:00:00Z"
}{
"payOutId": "660e8400-e29b-41d4-a716-446655440222",
"displayId": "PO00000002",
"amountFiat": 1500.00,
"amountUsdt": 1.47,
"currency": "VES",
"expiresAt": "2026-06-04T22:00:00Z"
}{
"payOutId": "770e8400-e29b-41d4-a716-446655440111",
"displayId": "PO00000041",
"amountFiat": 10000,
"amountUsdt": 102.50,
"currency": "INR",
"expiresAt": "2026-09-09T16:00:00Z"
}{
"payOutId": "770e8400-e29b-41d4-a716-446655440222",
"displayId": "PO00000042",
"amountFiat": 10000,
"amountUsdt": 102.50,
"currency": "INR",
"expiresAt": "2026-09-09T16:00:00Z",
"recipientUpiVpa": "ravi.kumar@ybl"
}{
"payOutId": "880e8400-e29b-41d4-a716-446655440333",
"displayId": "PO00000051",
"amountFiat": 150000,
"amountUsdt": 108.20,
"currency": "KRW",
"expiresAt": "2026-09-09T16:00:00Z"
}{
"error": "VALIDATION_ERROR",
"message": "recipientCbu is required for Argentina"
}{
"error": "VALIDATION_ERROR",
"message": "recipientPhone is required for Venezuela"
}{
"error": "VALIDATION_ERROR",
"message": "recipientUpiVpa is required for India UPI pay-outs"
}{
"error": "VALIDATION_ERROR",
"message": "recipientAccountNumber is required for SouthKorea"
}Get pay-out status
/api/merchant/pay-out/{payOutId}/statusReturns the current status of a pay-out, its amounts and the recipient details you sent.
Use it to check progress or to reconcile. Webhooks remain the signal for a final state: see the notes below.
No payOutId because the create request timed out? Look the pay-out up by your own id with Get pay-out status by merchantOrderId; it returns the same body.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
payOutId | UUID | Required | Identifier returned by Create a pay-out. |
Response fields 200 OK
| Field | Type | Description |
|---|---|---|
displayId | string | Human-readable identifier. |
status | enum | Pending, Claimed, PendingValidation, Completed, Expired, Failed, AwaitingFunds. See Status reference. |
amountFiat | decimal | Amount in the declared currency. |
currency | string | Currency code. |
amountUsdt | decimal | Equivalent USDT amount. |
geo | string | Region the pay-out belongs to. |
recipientName | string | Recipient name, as sent on creation. |
clientReference | string | null | Echo of the clientReference sent on creation. |
createdAt | datetime | When the pay-out was created. |
expiresAt | datetime | Expiration timestamp. |
completedAt | datetime | null | Non-null when status = Completed. |
expiredAt | datetime | null | Non-null when status = Expired. |
supportNote | string | null | Platform note attached at validation or rejection. |
Recipient fields
| Field | Type | Description |
|---|---|---|
recipientCbu | string | null | Destination CBU, as sent on creation. |
| Field | Type | Description |
|---|---|---|
recipientPhone | string | null | Destination wallet phone number, as sent on creation. |
recipientCedula | string | null | Recipient's national ID (cédula), as sent on creation. |
recipientBank | string | null | Destination bank or payment provider, as sent on creation. |
recipientAccountNumber | string | null | Bank account number (número de cuenta), as sent on creation. |
| Field | Type | Description |
|---|---|---|
recipientBank | string | null | Beneficiary bank name. Set on the imps rail; null on the UPI rail. |
recipientAccountNumber | string | null | IMPS beneficiary account number. Set on the imps rail; null on the UPI rail. |
recipientIfsc | string | null | IFSC branch code. Set on the imps rail; null on the UPI rail. |
recipientUpiVpa | string | New in v2.6. Beneficiary UPI ID / VPA, lower-cased. Present only on a UPI pay-out. The key is omitted entirely otherwise, so existing status responses are byte-identical to v2.5. |
| Field | Type | Description |
|---|---|---|
recipientBank | string | null | Destination bank, as sent on creation. |
recipientAccountNumber | string | null | kor_bank beneficiary account number, as sent on creation. |
Statuses
Enumerations are returned as strings; the numeric values are for reference. The same list is in the Status reference.
| Status | Numeric | Description |
|---|---|---|
| AwaitingFunds | 6 | A pay-out your pay-out balance could not cover at creation. Accepted (201) and held up to 12 h, then released or auto-expired. See the note below. |
| Pending | 0 | Funded and awaiting execution. |
| Claimed | 1 | Execution in progress. |
| PendingValidation | 2 | Transfer executed; awaiting platform verification. |
| Completed | 3 | Verified and finalized. Triggers pay_out.completed. |
| Expired | 4 | Lifetime elapsed before execution. Triggers pay_out.expired. |
| Failed | 5 | Rejected at validation, or cancelled by support before the transfer (the webhook reason starts with Cancelled by support:). Triggers pay_out.failed. |
Permitted transitions
AwaitingFunds → Pending // pay-out balance funded within 12 h AwaitingFunds → Expired // pay_out.expired — balance never arrived Pending → Claimed Claimed → PendingValidation PendingValidation → Completed PendingValidation → Failed PendingValidation → Pending // returned to pool Pending / Claimed → Expired AwaitingFunds / Pending / Claimed → Failed // cancelled by support
Notes
201 Created. This is not an error. The 201 body carries no status and no webhook announces the hold, so this endpoint is where you see AwaitingFunds. The pay-out is held for up to 12 hours from creation. Once the pay-out wallet covers it, it moves to Pending, its expiresAt is counted again from that moment, and it executes normally. If it is still not covered after 12 hours, it expires and fires pay_out.expired. Wait for the webhook rather than treating 201 as settled.
To fund held pay-outs, move USDT to your pay-out balance with Create a transfer or in the merchant dashboard, and check payOutBalance and reservedBalance with Get balance.
Completed, Failed and Expired are terminal. Treat them as final only after you have received the matching webhook and verified its signature.
{
"displayId": "PO00000001",
"status": "Completed",
"amountFiat": 5000.00,
"currency": "ARS",
"amountUsdt": 4.85,
"geo": "Argentina",
"recipientCbu": "0000177501057000189625",
"recipientName": "Maria Garcia Lopez",
"clientReference": "customer-7781",
"createdAt": "2026-04-14T15:30:00Z",
"expiresAt": "2026-04-14T16:00:00Z",
"completedAt": "2026-04-14T15:52:14Z",
"expiredAt": null,
"supportNote": "Proof verified"
}{
"displayId": "PO00000002",
"status": "AwaitingFunds",
"amountFiat": 1500.00,
"currency": "VES",
"amountUsdt": 1.47,
"geo": "Venezuela",
"recipientPhone": "+584121234567",
"recipientCedula": "V-12345678",
"recipientBank": "Banesco",
"recipientAccountNumber": "01020123456789012345",
"recipientName": "Carlos Rodríguez",
"clientReference": "customer-7781",
"createdAt": "2026-06-04T10:00:00Z",
"expiresAt": "2026-06-04T22:00:00Z",
"completedAt": null,
"expiredAt": null,
"supportNote": null
}{
"displayId": "PO00000041",
"status": "Completed",
"amountFiat": 10000,
"currency": "INR",
"amountUsdt": 102.50,
"geo": "India",
"recipientBank": "HDFC Bank",
"recipientAccountNumber": "40145477128",
"recipientIfsc": "HDFC0001234",
"recipientName": "Ravi Kumar",
"clientReference": "customer-7781",
"createdAt": "2026-09-09T15:30:00Z",
"expiresAt": "2026-09-09T16:00:00Z",
"completedAt": "2026-09-09T15:51:02Z",
"expiredAt": null,
"supportNote": null
}{
"displayId": "PO00000042",
"status": "Completed",
"amountFiat": 10000,
"currency": "INR",
"amountUsdt": 102.50,
"geo": "India",
"recipientBank": null,
"recipientAccountNumber": null,
"recipientIfsc": null,
"recipientUpiVpa": "ravi.kumar@ybl",
"recipientName": "Ravi Kumar",
"clientReference": "customer-7781",
"createdAt": "2026-09-09T15:30:00Z",
"expiresAt": "2026-09-09T16:00:00Z",
"completedAt": "2026-09-09T15:52:14Z",
"expiredAt": null,
"supportNote": null
}{
"displayId": "PO00000051",
"status": "Completed",
"amountFiat": 150000,
"currency": "KRW",
"amountUsdt": 108.20,
"geo": "SouthKorea",
"recipientBank": "KB Kookmin Bank",
"recipientAccountNumber": "110-123-456789",
"recipientName": "Kim Minseo",
"clientReference": "customer-7781",
"createdAt": "2026-09-09T15:30:00Z",
"expiresAt": "2026-09-09T16:00:00Z",
"completedAt": "2026-09-09T15:54:31Z",
"expiredAt": null,
"supportNote": null
}Get pay-out status by merchantOrderId
/api/merchant/pay-out/status?merchantOrderId={merchantOrderId}Finds a pay-out by your own merchantOrderId and returns its status. The response is the same as Get pay-out status.
New in v2.19. Use it when you do not have the payOutId, typically because the create request never returned a response.
When to use
Your POST /api/merchant/pay-out timed out or the connection dropped, so you have no payOutId and do not know whether the pay-out was created. Ask for the status by the merchantOrderId you sent:
Look the order up
Call this endpoint with the
merchantOrderIdof the create that got no response.200: the pay-out exists
Atlas received the create. Use the returned
statusand do not create the pay-out again. Keep reading the status here, or wait for the webhooks as for any other pay-out.404: the pay-out was not created
Repeat
POST /api/merchant/pay-outwith the samemerchantOrderIdand the same body. Create is idempotent bymerchantOrderId: if the first request reached Atlas after your lookup, the repeat returns that pay-out with 201 and does not create a second one. If the repeat itself fails (a timeout, a 5xx, or a 400 you did not get before), look the order up again before the next attempt: the first request may have been completed in the meantime.
The status body has no payOutId; displayId identifies the pay-out. If you need the payOutId, for example to open a dispute, repeat the create with the same merchantOrderId and the same body: for a pay-out that has not failed it returns that pay-out with 201 and its payOutId, and nothing new is created. A Failed or Expired pay-out is final, and a repeat does not create a new one. To send the money again, create a new pay-out with a new merchantOrderId.
Request
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
merchantOrderId | string | Required | The merchantOrderId you sent to Create a pay-out, up to 200 characters. Matched exactly: case-sensitive and not trimmed. Send it URL-encoded, see below. |
Encode the merchantOrderId
The value goes in the query string, so encode it as a query component: encodeURIComponent in JavaScript, urllib.parse.quote(value, safe="") in Python, Uri.EscapeDataString in C#, or curl -G --data-urlencode "merchantOrderId=…". These characters change the meaning of the URL if you send them raw:
| Character | Send as | Sent raw |
|---|---|---|
+ | %2B | Read as a space. |
& | %26 | Starts a new parameter, so the id is cut short. |
# | %23 | Starts the fragment. The client does not send the rest of the URL. |
% | %25 | Read as the start of an escape sequence. |
The functions above also encode /, ? and spaces; the server decodes them. The id is a query parameter rather than a path segment because ids can contain /, ., ?, # and %. In a path, an encoded slash (%2F) is not decoded by the server, and HTTP clients and proxies normalise . and .. segments away. A query parameter is decoded the same way for every character.
X-Api-Key, X-Timestamp, X-Signature), as in Authentication. The canonical string for this GET is the empty-body form <X-Timestamp>.. Query parameters are not included in the signature.Response
200 OK with the same body as Get pay-out status returns for that pay-out. See the response fields and statuses there.
Errors
| HTTP status | Error code | Message |
|---|---|---|
| 400 | VALIDATION_ERROR | "merchantOrderId is required": the parameter is missing or blank. "merchantOrderId exceeds 200 character limit": the value is longer than 200 characters. |
| 404 | NOT_FOUND | "PayOut not found": your account has no pay-out with this merchantOrderId. Another merchant's pay-out with the same id is not visible to you and also gives 404. A first create refused with 400 (for example VALIDATION_ERROR or AMOUNT_ABOVE_MAXIMUM) creates no pay-out, so its merchantOrderId gives 404 as well. IDEMPOTENCY_KEY_MISMATCH is the exception: it means a pay-out with that merchantOrderId already exists, and the lookup returns it. |
| 401 | See Authentication | Authentication failed. |
Every error code is listed in the error reference.
See also
- Get pay-out status by
payOutId, with the response fields and statuses. - Get pay-in status by merchantOrderId, the same lookup for pay-ins.
- Webhook events for
pay_out.completed,pay_out.failedandpay_out.expired.
TIMESTAMP=$(date +%s) SIGNATURE=$(printf '%s.' "$TIMESTAMP" \ | openssl dgst -sha256 -hmac "$ATLAS_API_SECRET" | sed 's/^.*= //') # -G + --data-urlencode puts the encoded value in the query string curl -G https://api.atlas-online.io/api/merchant/pay-out/status \ --data-urlencode "merchantOrderId=payout-67890" \ -H "X-Api-Key: $ATLAS_API_KEY" \ -H "X-Timestamp: $TIMESTAMP" \ -H "X-Signature: $SIGNATURE"
// call() is the helper from Shared helpers async function findPayOut(merchantOrderId) { const q = encodeURIComponent(merchantOrderId); // + → %2B, & → %26 try { return await call('GET', `/api/merchant/pay-out/status?merchantOrderId=${q}`); } catch (err) { if (err.status === 404) return null; // not created throw err; } } // POST /api/merchant/pay-out timed out: const found = await findPayOut(order.merchantOrderId); if (found === null) { // Same merchantOrderId, same body: never a second pay-out. await call('POST', '/api/merchant/pay-out', order); } else { console.log(found.displayId, found.status); // exists: do not create }
import hashlib, hmac, time, urllib.parse import requests def find_pay_out(merchant_order_id: str): ts = str(int(time.time())) sig = hmac.new(API_SECRET.encode(), f"{ts}.".encode(), hashlib.sha256).hexdigest() # GET: empty body q = urllib.parse.quote(merchant_order_id, safe="") r = requests.get( f"{BASE_URL}/api/merchant/pay-out/status?merchantOrderId={q}", headers={"X-Api-Key": API_KEY, "X-Timestamp": ts, "X-Signature": sig}, timeout=30, ) if r.status_code == 404: return None # not created: repeat the create, same body r.raise_for_status() return r.json()
{
"displayId": "PO00000001",
"status": "Completed",
"amountFiat": 5000.00,
"currency": "ARS",
"amountUsdt": 4.85,
"geo": "Argentina",
"recipientCbu": "0000177501057000189625",
"recipientName": "Maria Garcia Lopez",
"clientReference": "customer-7781",
"createdAt": "2026-04-14T15:30:00Z",
"expiresAt": "2026-04-14T16:00:00Z",
"completedAt": "2026-04-14T15:52:14Z",
"expiredAt": null,
"supportNote": "Proof verified"
}{
"displayId": "PO00000002",
"status": "AwaitingFunds",
"amountFiat": 1500.00,
"currency": "VES",
"amountUsdt": 1.47,
"geo": "Venezuela",
"recipientPhone": "+584121234567",
"recipientCedula": "V-12345678",
"recipientBank": "Banesco",
"recipientAccountNumber": "01020123456789012345",
"recipientName": "Carlos Rodríguez",
"clientReference": "customer-7781",
"createdAt": "2026-06-04T10:00:00Z",
"expiresAt": "2026-06-04T22:00:00Z",
"completedAt": null,
"expiredAt": null,
"supportNote": null
}{
"displayId": "PO00000041",
"status": "Completed",
"amountFiat": 10000,
"currency": "INR",
"amountUsdt": 102.50,
"geo": "India",
"recipientBank": "HDFC Bank",
"recipientAccountNumber": "40145477128",
"recipientIfsc": "HDFC0001234",
"recipientName": "Ravi Kumar",
"clientReference": "customer-7781",
"createdAt": "2026-09-09T15:30:00Z",
"expiresAt": "2026-09-09T16:00:00Z",
"completedAt": "2026-09-09T15:51:02Z",
"expiredAt": null,
"supportNote": null
}{
"displayId": "PO00000051",
"status": "Completed",
"amountFiat": 150000,
"currency": "KRW",
"amountUsdt": 108.20,
"geo": "SouthKorea",
"recipientBank": "KB Kookmin Bank",
"recipientAccountNumber": "110-123-456789",
"recipientName": "Kim Minseo",
"clientReference": "customer-7781",
"createdAt": "2026-09-09T15:30:00Z",
"expiresAt": "2026-09-09T16:00:00Z",
"completedAt": "2026-09-09T15:54:31Z",
"expiredAt": null,
"supportNote": null
}{
"error": "NOT_FOUND",
"message": "PayOut not found"
}{
"error": "VALIDATION_ERROR",
"message": "merchantOrderId is required"
}List banks
/api/merchant/banks?geo={geo}Returns the supported bank directory for Venezuela.
Returns the South Korean bank catalog (v2.3).
Use it to populate a bank selector when you create a pay-out: pass the code (or name) as the pay-out recipientBank in Create a pay-out.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
geo | enum | Required | Pass Venezuela to get this catalog.Pass SouthKorea to get this catalog. An unrecognised value returns VALIDATION_ERROR. |
Response fields 200 OK
An array of entries:
| Field | Type | Description |
|---|---|---|
code | string | 4-digit SUDEBAN code (e.g. "0134" Banesco). |
code | string | Stable constant, stored lowercase (e.g. "kb_kookmin_bank"). |
name | string | Display name. Suitable to render verbatim in a dropdown. |
kind | string | Always Bank today. The value Wallet is reserved for future wallet-style catalogs. (v2.2 doc correction: earlier versions of this page showed Wallet entries, but the endpoint has never returned them.) |
Notes
[]. recipientBank is not checked against this list, so an empty list never blocks a pay-out: send the recipient's 4-digit SUDEBAN bank code or the bank name. A value that matches a code from List payment methods is priced as that method; any other value is priced with your account's default pay-out terms.
X-Api-Key, X-Timestamp, X-Signature), as in Authentication. The canonical string for this GET is the empty-body form <X-Timestamp>.. Query parameters are not included in the signature.
code values are stable across releases. Store them on your side and map them to your own display labels if you need localized names.
Product overview: Pay-outs, Pago Móvil, Venezuela.
Product overview: Pay-outs, Korean bank transfer, South Korea.
[
{ "code": "kb_kookmin_bank", "name": "KB Kookmin Bank", "kind": "Bank" },
{ "code": "shinhan_bank", "name": "Shinhan Bank", "kind": "Bank" },
{ "code": "woori_bank", "name": "Woori Bank", "kind": "Bank" },
{ "code": "hana_bank", "name": "Hana Bank", "kind": "Bank" },
{ "code": "kakao_bank", "name": "KakaoBank", "kind": "Bank" },
{ "code": "toss_bank", "name": "Toss Bank", "kind": "Bank" }
]
[
{ "code": "0102", "name": "Banco de Venezuela", "kind": "Bank" },
{ "code": "0105", "name": "Banco Mercantil", "kind": "Bank" },
{ "code": "0108", "name": "BBVA Provincial", "kind": "Bank" },
{ "code": "0134", "name": "Banesco", "kind": "Bank" },
{ "code": "0172", "name": "Bancamiga", "kind": "Bank" },
{ "code": "0191", "name": "Banco Nacional de Crédito (BNC)", "kind": "Bank" }
]
List payment methods
/api/merchant/payment-methods?geo={geo}Returns the payment methods available to this merchant for the requested geo, in { code, name, type } shape.
Pass the code value as the paymentMethod field on pay-in create (optionalrequired). type is the rail identifier, so several methods may share one: upi and upi_intent are both type: "upi". Added in v2.0; code added in v2.1; per-method rail in v2.2; upi_intent in v2.6; South Korea in v2.3.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
geo | enum | Required | Pass ArgentinaVenezuelaIndiaSouthKorea. An unrecognised value returns VALIDATION_ERROR. |
Response fields 200 OK
An array of entries:
| Field | Type | Description |
|---|---|---|
code | string | Method code (e.g. 0102, 0108) (e.g. upi, upi_intent, imps) (e.g. kor_bank, kakao_pay). Pass this value as paymentMethod on pay-in create. |
name | string | Human-readable method name (e.g. "Banco de Venezuela") (e.g. "UPI", "UPI Intent") (e.g. "Kakao Pay"). Suitable to render verbatim in a dropdown. |
type | string | Rail identifier: the geo-level rail. Value: cvu. |
type | string | Rail identifier: the geo-level rail. Values: ven_bank, ven_bank_account. |
type | string | Rail identifier. Since v2.2 type is carried per method, but several methods may share one rail: upi and upi_intent are both type: "upi" (v2.6), which is why the same receiving accounts serve them, and imps has its own. Values: upi, imps. |
type | string | Rail identifier. Since v2.2 it is per-method: kor_bank and kakao_pay coexist under one geo (v2.3). Values: kor_bank, kakao_pay. |
Notes
X-Api-Key, X-Timestamp, X-Signature), as in Authentication. The canonical string for this GET is the empty-body form <X-Timestamp>.. Query parameters are not included in the signature.
Product overview: CVU/CBU, Argentina: Pago Móvil, Venezuela: UPI, IMPS, India: Kakao Pay, Korean bank transfer, South Korea.
[
{ "code": "cvu", "name": "CVU transfer", "type": "cvu" }
][
{ "code": "0102", "name": "Banco de Venezuela", "type": "ven_bank" },
{ "code": "0108", "name": "Provincial", "type": "ven_bank" },
{ "code": "0134", "name": "Banesco", "type": "ven_bank" }
][
{ "code": "imps", "name": "IMPS", "type": "imps" },
{ "code": "upi", "name": "UPI", "type": "upi" },
{ "code": "upi_intent", "name": "UPI Intent", "type": "upi" }
][
{ "code": "kakao_pay", "name": "Kakao Pay", "type": "kakao_pay" },
{ "code": "kor_bank", "name": "Bank Transfer", "type": "kor_bank" }
]Open a dispute
/api/merchant/disputesContests a completed pay-in or pay-out. The platform holds the underlying funds until the dispute is resolved, and a 24-hour evidence deadline applies.
Dispute lifecycle
Open the dispute
Send
POST /api/merchant/disputesfor a completed pay-in or pay-out. The dispute starts inPendingEvidence, and itsevidenceDeadlineis 24 h after creation. The platform sends adispute.openedwebhook (see Webhook events).Exchange messages and evidence
Post text and files to the thread with Post a message or evidence: up to 10 files per request, combined request size up to 50 MB. Read the thread with List dispute messages.
Review
The receiving side in the Atlas network owes evidence within 24 h. Once it answers, the dispute moves to
UnderReviewand Atlas reviews it. If the deadline passes without its evidence, the dispute ends asEvidenceExpired, in your favor.Resolution
The dispute ends in
ResolvedForTrader,ResolvedForClientorEvidenceExpired, and the platform sends adispute.resolvedwebhook. Check the outcome with Get a dispute.
Statuses
| Status | Numeric value | Meaning |
|---|---|---|
| PendingEvidence | 0 | Waiting for evidence from the receiving side, due within 24 h. Your own messages and files are optional. |
| UnderReview | 1 | The receiving side has answered with evidence. Atlas is reviewing the dispute. |
| ResolvedForTrader | 2 | Resolved against you. |
| ResolvedForClient | 3 | Resolved in your favor. |
| EvidenceExpired | 4 | The receiving side sent no evidence by the deadline. Resolved in your favor automatically. |
In these values Client means you, the merchant, and Trader means the receiving side in the Atlas network, so ResolvedForTrader is a dispute resolved against you. The dispute.resolved webhook reports both outcomes in your favor as resolvedInFavorOf: "client" and an outcome against you as resolvedInFavorOf: "trader".
Permitted transitions
PendingEvidence → UnderReview PendingEvidence → ResolvedForTrader PendingEvidence → ResolvedForClient PendingEvidence → EvidenceExpired UnderReview → ResolvedForTrader UnderReview → ResolvedForClient
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
transactionType | integer | string | Required | 0 or PayIn for a pay-in; 1 or PayOut for a pay-out. |
transactionId | UUID | Required | Identifier of the disputed pay-in or pay-out. |
reason | string | Required | Your reason for the dispute, up to 2000 characters. |
merchantOrderId | string | Required | Idempotency key, unique per merchant. |
disputeAmount | decimal | Required | The contested amount in the transaction currency. May be partial (0 < disputeAmount ≤ transaction amount). |
Response fields 201 Created
| Field | Type | Description |
|---|---|---|
disputeId | UUID | Platform identifier. Use it as id in the other dispute calls. |
displayId | string | Human-readable identifier (DS00000001). |
status | enum | Always PendingEvidence on creation. |
evidenceDeadline | datetime | 24 h after creation. |
Errors
| HTTP status | Error code | When it happens |
|---|---|---|
| 400 | VALIDATION_ERROR | disputeAmount is missing, zero or negative: "disputeAmount must be greater than zero". |
| 400 | VALIDATION_ERROR | disputeAmount is larger than the transaction amount; unknown or foreign transaction; an active dispute already exists; the merchant is inactive. |
| 401 | See Authentication | Authentication failure. |
{
"transactionType": 0,
"transactionId": "1c75c312-93f3-4b34-903e-10fb38a56bec",
"reason": "Client claims payment was sent but balance not credited.",
"merchantOrderId": "order-12345",
"disputeAmount": 2500.00
"disputeAmount": 750.00
"disputeAmount": 2500
"disputeAmount": 25000
}
{
"disputeId": "770e8400-e29b-41d4-a716-446655440222",
"displayId": "DS00000001",
"status": "PendingEvidence",
"evidenceDeadline": "2026-04-15T15:30:00Z"
}
Get a dispute
/api/merchant/disputes/{id}Returns the dispute with its full merchant-visible message thread.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | UUID | Required | Dispute identifier. |
Response 200 OK
The sample on the right shows the full shape. status takes the values listed in Dispute lifecycle.
Errors
| HTTP status | Error code | When it happens |
|---|---|---|
| 404 | NOT_FOUND | The dispute does not exist or belongs to another merchant. |
| 401 | See Authentication | Authentication failure. |
merchantThread. Messages exchanged between the platform operator and the counterparty are not exposed.
{
"id": "770e8400-e29b-41d4-a716-446655440222",
"displayId": "DS00000001",
"merchantOrderId": "order-12345",
"disputeType": "PayIn",
"transactionId": "1c75c312-93f3-4b34-903e-10fb38a56bec",
"amountFiat": 5000.00,
"currency": "ARS",
"geo": "Argentina",
"amountFiat": 1500.00,
"currency": "VES",
"geo": "Venezuela",
"amountFiat": 5000,
"currency": "INR",
"geo": "India",
"amountFiat": 50000,
"currency": "KRW",
"geo": "SouthKorea",
"merchantReason": "Client claims payment was sent...",
"status": "PendingEvidence",
"evidenceDeadline": "2026-04-15T15:30:00Z",
"isOverdue": false,
"resolution": null,
"createdAt": "2026-04-14T15:30:00Z",
"resolvedAt": null,
"merchantThread": [
{
"id": "990e8400-e29b-41d4-a716-446655440444",
"senderType": "System",
"senderName": "System",
"body": "Dispute opened. Under review.",
"createdAt": "2026-04-14T15:30:00Z",
"attachments": []
}
]
}
Post a message or evidence
/api/merchant/disputes/{id}/messagesPosts a text message and up to 10 attachment files to the dispute thread. Send it as multipart/form-data, signed over the multipart body as sent (see Authentication).
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | UUID | Required | Dispute identifier. |
Form fields
| Name | Type | Required | Description |
|---|---|---|---|
body | string | Required | Message text. |
files | file[] | Optional | Up to 10 files per request, combined request size up to 50 MB. Only the first 10 files are stored; any file after the tenth is dropped without an error. |
Limits
| Limit | Value |
|---|---|
| Max files per request | 10. Files after the tenth are dropped and the call still returns 200; post them in another message. |
| Max combined request size | 50 MB. A larger request is refused with 413 and no JSON body. |
| File type and size per file | Not checked; stay within the combined size. |
Response 200 OK
A confirmation message, as in the sample. Read the stored message and attachment URLs with List dispute messages.
Errors
The dispute is checked before any file is stored, so a refused call stores nothing. A dispute that does not exist or belongs to another merchant gets 404 NOT_FOUND, as on the other dispute endpoints. Other refusals use {"error": "<text>"}: the error field holds a readable sentence, not a code, and there is no message field. Match on the HTTP status and log the text.
| HTTP status | Body | When it happens |
|---|---|---|
| 404 | NOT_FOUND | The dispute does not exist or belongs to another merchant. |
| 400 | {"error": "Cannot add messages to a resolved dispute"} | The dispute is ResolvedForTrader, ResolvedForClient or EvidenceExpired. |
| 400 | Validation problem document (title, status, errors) | The body form field is missing or empty, or id is not a UUID. |
| 413 | No JSON body | The request is larger than 50 MB. |
| 401 | See Authentication | Authentication failure. These keep the usual {"error": "<CODE>", "message": "<text>"} shape. |
{ "message": "Message sent" }
List dispute messages
/api/merchant/disputes/{id}/messagesReturns the merchant-visible message thread of the dispute, with any attachments uploaded by the merchant or added by the platform.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | UUID | Required | Dispute identifier. |
Response 200 OK
A messages array. Each message carries id, senderType, senderName, body, createdAt and attachments; each attachment carries id, fileName, fileUrl, contentType and fileSize.
Errors
| HTTP status | Error code | When it happens |
|---|---|---|
| 404 | NOT_FOUND | The dispute does not exist or belongs to another merchant. |
| 401 | See Authentication | Authentication failure. |
{
"messages": [
{
"id": "bb0e8400-...",
"senderType": "MerchantApi",
"senderName": "Merchant",
"body": "Evidence attached",
"createdAt": "2026-04-14T15:35:00Z",
"attachments": [
{
"id": "cc0e8400-...",
"fileName": "proof.pdf",
"fileUrl": "https://api.atlas-online.io/storage/disputes/.../proof.pdf",
"contentType": "application/pdf",
"fileSize": 250000
}
]
}
]
}
Get balance
/api/merchant/balanceReturns your USDT position across your balances, plus the withdrawal in flight, if any. The figures match the Finance page of the merchant dashboard.
Your balances
Everything on your account is held in USDT. The merchant dashboard shows the first two balances as the pay-in wallet and the pay-out wallet.
| Balance | Field | What it holds |
|---|---|---|
| Pay-in balance | availableBalance | Net proceeds of completed pay-ins. This is the amount you can withdraw. |
| Pay-out balance | payOutBalance | Funds that back your pay-out reservations. |
| Reserved | reservedBalance | USDT locked by pay-outs that are still in flight. |
| Total | totalBalance | availableBalance + payOutBalance + reservedBalance. |
How money moves
Pay-ins credit the pay-in balance
The net proceeds of every completed pay-in land in your pay-in balance.
You move funds to the pay-out balance
Move USDT to your pay-out balance with Create a transfer or from the merchant dashboard. Free USDT (not reserved by a pay-out) can be moved back to the pay-in balance from the merchant dashboard.
Pay-outs reserve from the pay-out balance
The pay-out balance backs your pay-outs. USDT locked by a pay-out in flight shows in
reservedBalance.Withdrawals leave the pay-in balance
Request a withdrawal to your USDT (TRC-20) wallet with Request a withdrawal, or let auto-settle request one when your balance reaches the threshold.
Response fields 200 OK
| Field | Type | Description |
|---|---|---|
currency | string | Always USDT. |
availableBalance | decimal | The pay-in balance. Net proceeds of completed pay-ins and the amount you can withdraw. |
payOutBalance | decimal | The pay-out balance. Funds that back your pay-out reservations, moved here from the pay-in balance in the merchant dashboard or with Create a transfer. |
reservedBalance | decimal | USDT locked by pay-outs that are still in flight. |
totalBalance | decimal | availableBalance + payOutBalance + reservedBalance. |
walletAddress | string | null | The USDT (TRC-20) address withdrawals are sent to. null until you set it in the merchant dashboard (Settings → Settlement wallet). Until then, withdrawals are rejected with NO_WALLET_ADDRESS. |
autoSettleEnabled | boolean | When true, a withdrawal of the whole pay-in balance is requested for you automatically once it reaches accumulationThreshold. That withdrawal may be sent to your wallet automatically (it is Processing while the transfer is on its way) or paid by the platform's finance team like any other. The platform manages this setting. |
accumulationThreshold | decimal | Auto-settle trigger level, in USDT. |
pendingWithdrawal | object | null | The withdrawal that is currently Pending or Processing, with withdrawalId, amount, status and createdAt. Processing means an automatic transfer is on its way. null when no withdrawal is in flight. |
Errors
| HTTP status | Error code | When it happens |
|---|---|---|
| 401 | See Authentication | The request signature or API key was rejected. |
Signing a GET request
GET requests have no body, so the canonical string is <X-Timestamp>.: the timestamp, a dot, and nothing after it. Sign it the same way as any other request.
GET /api/merchant/balance
{
"currency": "USDT",
"availableBalance": 1250.50,
"payOutBalance": 300.00,
"reservedBalance": 25.00,
"totalBalance": 1575.50,
"walletAddress": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
"autoSettleEnabled": false,
"accumulationThreshold": 500.00,
"pendingWithdrawal": {
"withdrawalId": "6f1c2a0e-9b7d-4e21-8c3a-2f4b5d6e7a80",
"amount": 100.00,
"status": "Processing",
"createdAt": "2026-09-02T09:15:00Z"
}
}
Request a withdrawal
/api/merchant/withdrawalsSends USDT from your pay-in balance to the wallet address set for your account. The amount leaves availableBalance immediately.
Body parameters
The body is optional. Send no body, or omit amount, to withdraw the whole available balance.
| Name | Type | Required | Description |
|---|---|---|---|
amount | decimal | Optional | USDT to withdraw, > 0 and ≤ availableBalance. Omit the field, or send no body at all, to withdraw the whole available balance. |
Response fields 201 Created
| Field | Type | Description |
|---|---|---|
withdrawalId | UUID | Use it with Get a withdrawal. |
amount | decimal | USDT withdrawn. |
currency | string | Always USDT. |
walletAddress | string | Destination address, as it was at request time. |
status | enum | Pending, Processing, Completed or Failed. |
txHash | string | null | On-chain (TRON) transaction id of the transfer. Set when the withdrawal is Completed; still null while it is Processing. |
failureReason | string | null | Set when status = Failed. |
createdAt | datetime | When the withdrawal was created. |
submittedAt | datetime | null | When the transfer was broadcast. Set while Processing for an automatic transfer. |
confirmedAt | datetime | null | Non-null when status = Completed. |
Errors
| HTTP status | Error code | When it happens |
|---|---|---|
| 400 | VALIDATION_ERROR | amount is present but not greater than zero. |
| 400 | NO_WALLET_ADDRESS | No valid USDT (TRC-20) wallet address is set for the merchant. Set it in the merchant dashboard (Settings → Settlement wallet) first. |
| 400 | INSUFFICIENT_BALANCE | The pay-in balance is empty, or amount exceeds availableBalance. The message includes the available, required and shortfall amounts. |
| 400 | WITHDRAWAL_IN_FLIGHT | A withdrawal is already Pending or Processing. |
How a withdrawal is paid
A new withdrawal starts in status Pending. The platform's finance team reviews it and pays it out. Once the USDT transfer is made, the withdrawal becomes Completed and carries its on-chain txHash. A declined withdrawal becomes Failed, with the reason in failureReason, and the amount goes back to the pay-in balance.
The wallet address is fixed at request time. Changing it in the merchant dashboard afterwards does not redirect a withdrawal you already requested.
Automatic settlement
With autoSettleEnabled, a withdrawal is requested for you when the pay-in balance reaches accumulationThreshold (see settlement). That withdrawal may be sent automatically: its status becomes Processing while the transfer is on its way, then Completed with the txHash once it is confirmed on chain. If the automatic transfer cannot be made, the withdrawal goes back to Pending and the finance team pays it as usual. A withdrawal you request yourself is never sent automatically.
Atlas can also set a pay-out balance top-up for your account: when the threshold is reached, a fixed amount first moves from the pay-in balance to the pay-out balance, and the withdrawal is for the rest. With a threshold of 1,000 USDT and a top-up of 200 USDT, 200 USDT goes to payOutBalance and the withdrawal is for 800 USDT. The move appears in List transfers with source: "autoSettle". If that withdrawal is rejected, only the withdrawn part returns to the pay-in balance; the top-up stays on the pay-out balance.
Pending or Processing, new requests are rejected with WITHDRAWAL_IN_FLIGHT. Poll Get a withdrawal, or read pendingWithdrawal from Get balance, until it settles. This also makes retries safe: a duplicate request can never withdraw twice.Status changes
Pending → Completed // paid out by the platform, txHash set Pending → Failed // declined: failureReason set, amount refunded to the pay-in balance Pending → Processing // automatic settlement: the transfer is on its way Processing → Completed // confirmed on chain, txHash set Processing → Pending // the automatic transfer could not be made — paid by hand instead
Processing means an automatic transfer is on its way, and txHash stays null until the withdrawal is Completed. Only a withdrawal requested automatically at the threshold is ever Processing. Keep polling until you see Completed or Failed. A return to Pending only means the finance team will pay it by hand.
{
"amount": 100.00
}
{
"withdrawalId": "6f1c2a0e-9b7d-4e21-8c3a-2f4b5d6e7a80",
"amount": 100.00,
"currency": "USDT",
"walletAddress": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
"status": "Pending",
"txHash": null,
"failureReason": null,
"createdAt": "2026-09-02T09:15:00Z",
"submittedAt": null,
"confirmedAt": null
}
{
"error": "WITHDRAWAL_IN_FLIGHT",
"message": "A withdrawal is already in flight (Pending or Processing). Wait for it to complete before requesting another."
}
Get a withdrawal
/api/merchant/withdrawals/{withdrawalId}Returns the current state of one withdrawal.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
withdrawalId | UUID | Required | Identifier returned by Request a withdrawal. |
Response fields 200 OK
Same shape as the Request a withdrawal response.
Errors
| HTTP status | Error code | When it happens |
|---|---|---|
| 404 | NOT_FOUND | The id does not exist or belongs to another merchant. |
| 401 | See Authentication | The request signature or API key was rejected. |
GET /api/merchant/withdrawals/6f1c2a0e-9b7d-4e21-8c3a-2f4b5d6e7a80
{
"withdrawalId": "6f1c2a0e-9b7d-4e21-8c3a-2f4b5d6e7a80",
"amount": 100.00,
"currency": "USDT",
"walletAddress": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
"status": "Completed",
"txHash": "5c2b0f9e7a8d4c1b3e6f0a9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b",
"failureReason": null,
"createdAt": "2026-09-02T09:15:00Z",
"submittedAt": "2026-09-02T09:16:02Z",
"confirmedAt": "2026-09-02T09:19:40Z"
}
List withdrawals
/api/merchant/withdrawalsReturns your withdrawals, newest first, with an optional status filter and paging.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
page | integer | Optional | 1-based page number. Default 1. |
pageSize | integer | Optional | Items per page, 1–100. Default 20. |
status | enum | Optional | Return only withdrawals in this status: Pending, Processing, Completed or Failed. |
Response fields 200 OK
| Field | Type | Description |
|---|---|---|
items | array | Withdrawal objects, same shape as Get a withdrawal. |
totalCount | integer | Total matching withdrawals. |
page | integer | Page number of this response. |
pageSize | integer | Items per page in this response. |
Errors
| HTTP status | Error code | When it happens |
|---|---|---|
| 401 | See Authentication | The request signature or API key was rejected. |
GET /api/merchant/withdrawals?page=1&pageSize=20&status=Completed
{
"items": [
{
"withdrawalId": "6f1c2a0e-9b7d-4e21-8c3a-2f4b5d6e7a80",
"amount": 100.00,
"currency": "USDT",
"walletAddress": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
"status": "Completed",
"txHash": "5c2b0f9e7a8d4c1b3e6f0a9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b",
"failureReason": null,
"createdAt": "2026-09-02T09:15:00Z",
"submittedAt": "2026-09-02T09:16:02Z",
"confirmedAt": "2026-09-02T09:19:40Z"
}
],
"totalCount": 1,
"page": 1,
"pageSize": 20
}
Create a transfer
/api/merchant/transfersMoves USDT from your pay-in balance (availableBalance) to your pay-out balance (payOutBalance), which backs your pay-out reservations. It is the same move as the transfer to the pay-out wallet in the merchant dashboard.
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
amount | decimal | Required | USDT to move, > 0, at most 6 decimal places and ≤ availableBalance. |
clientReference | string | Required | Your idempotency key, unique per merchant: 1–64 characters of A–Z a–z 0–9 . _ : -. Use a new one for every new transfer. |
Response fields 201 Created
A repeat with the same clientReference and amount returns the same body with 200 OK.
| Field | Type | Description |
|---|---|---|
transferId | UUID | Use it with Get a transfer. |
clientReference | string | null | Your reference. null for a transfer made in the merchant dashboard. |
amount | decimal | USDT moved. |
currency | string | Always USDT. |
direction | string | Always payInToPayOut (pay-in balance to pay-out balance). |
source | string | api for a transfer made through this endpoint, manual for one made in the merchant dashboard or by ATLAS staff at your request, autoSettle for the pay-out balance top-up that automatic settlement moves before a withdrawal (see Automatic settlement), payOutWalletFill for part of a pay-in credit moved to the pay-out balance when ATLAS has set a pay-out balance fill level for you. Accept unknown values: more sources may be added. |
createdAt | datetime | When the transfer was booked. |
balances | object | Your balances right after this transfer was booked: availableBalance, payOutBalance, reservedBalance and totalBalance, with the field names of Get balance. A repeat returns the same figures. |
Errors
| HTTP status | Error code | When it happens |
|---|---|---|
| 400 | VALIDATION_ERROR | amount or clientReference is missing, amount is not positive or has more than 6 decimal places, or clientReference is longer than 64 characters or uses other characters. |
| 400 | INSUFFICIENT_BALANCE | amount exceeds availableBalance, including when a concurrent withdrawal or transfer used the balance first. The message includes the available, required and shortfall amounts. |
| 409 | IDEMPOTENCY_KEY_MISMATCH | clientReference was already used for a transfer of a different amount. Nothing moved. |
| 401 | See Authentication | The request signature or API key was rejected. |
Idempotency
clientReference is your key for the transfer and is unique per merchant. If a request times out, send it again with the same clientReference and amount. You get the original transfer back with 200, and nothing moves a second time. The same reference with a different amount is refused with 409 IDEMPOTENCY_KEY_MISMATCH.
Concurrent requests (two transfers, or a transfer and a withdrawal) can never take the pay-in balance below zero. The request that no longer fits gets INSUFFICIENT_BALANCE.
Synchronous, no webhook
A 201 response means the USDT is already in the pay-out balance, and no webhook follows. The API moves USDT one way, from the pay-in balance to the pay-out balance. A move back is made in the merchant dashboard: it changes the balances in Get balance but is not listed as a transfer.
Pay-outs held for funds (AwaitingFunds) that the pay-out balance can now cover are released right after the transfer, oldest first.
balances shows your balances at the moment the transfer was booked. Pay-outs released by the transfer are reserved after this point, so read Get balance for your live position.{
"amount": 250.00,
"clientReference": "fund-2026-10-12-001"
}
{
"transferId": "3b9e7c51-0d2a-4f68-9a1e-5c7d2b8f4e10",
"clientReference": "fund-2026-10-12-001",
"amount": 250.00,
"currency": "USDT",
"direction": "payInToPayOut",
"source": "api",
"createdAt": "2026-10-12T10:30:00Z",
"balances": {
"availableBalance": 1000.50,
"payOutBalance": 550.00,
"reservedBalance": 25.00,
"totalBalance": 1575.50
}
}
{
"error": "IDEMPOTENCY_KEY_MISMATCH",
"message": "clientReference 'fund-2026-10-12-001' was already used for a transfer of 250 USDT. Use a fresh clientReference for a new transfer."
}
Get a transfer
/api/merchant/transfers/{transferId}Returns one transfer from the pay-in balance to the pay-out balance, made through the API or in the merchant dashboard.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
transferId | UUID | Required | Identifier returned by Create a transfer or listed by List transfers. |
Response fields 200 OK
Same shape as the Create a transfer response.
Errors
| HTTP status | Error code | When it happens |
|---|---|---|
| 404 | NOT_FOUND | The id does not exist or belongs to another merchant. |
| 401 | See Authentication | The request signature or API key was rejected. |
GET /api/merchant/transfers/3b9e7c51-0d2a-4f68-9a1e-5c7d2b8f4e10
{
"transferId": "3b9e7c51-0d2a-4f68-9a1e-5c7d2b8f4e10",
"clientReference": "fund-2026-10-12-001",
"amount": 250.00,
"currency": "USDT",
"direction": "payInToPayOut",
"source": "api",
"createdAt": "2026-10-12T10:30:00Z",
"balances": {
"availableBalance": 1000.50,
"payOutBalance": 550.00,
"reservedBalance": 25.00,
"totalBalance": 1575.50
}
}
List transfers
/api/merchant/transfersReturns your transfers from the pay-in balance to the pay-out balance, newest first, with paging. Transfers made in the merchant dashboard or by ATLAS staff are listed too, with source: "manual" and clientReference: null, and so are the top-ups automatic settlement moves, with source: "autoSettle" and clientReference: null.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
page | integer | Optional | 1-based page number. Default 1. |
pageSize | integer | Optional | Items per page, 1–100. Default 20. |
Response fields 200 OK
| Field | Type | Description |
|---|---|---|
items | array | Transfer objects, same shape as Get a transfer. |
totalCount | integer | Total transfers. |
page | integer | Page number of this response. |
pageSize | integer | Items per page in this response. |
Errors
| HTTP status | Error code | When it happens |
|---|---|---|
| 401 | See Authentication | The request signature or API key was rejected. |
GET /api/merchant/transfers?page=1&pageSize=20
{
"items": [
{
"transferId": "3b9e7c51-0d2a-4f68-9a1e-5c7d2b8f4e10",
"clientReference": "fund-2026-10-12-001",
"amount": 250.00,
"currency": "USDT",
"direction": "payInToPayOut",
"source": "api",
"createdAt": "2026-10-12T10:30:00Z",
"balances": {
"availableBalance": 1000.50,
"payOutBalance": 550.00,
"reservedBalance": 25.00,
"totalBalance": 1575.50
}
},
{
"transferId": "c41f0a7e-6b3d-4e95-8f22-1a9d0c6b7e35",
"clientReference": null,
"amount": 300.00,
"currency": "USDT",
"direction": "payInToPayOut",
"source": "manual",
"createdAt": "2026-10-10T16:05:12Z",
"balances": {
"availableBalance": 900.00,
"payOutBalance": 325.00,
"reservedBalance": 0.00,
"totalBalance": 1225.00
}
}
],
"totalCount": 2,
"page": 1,
"pageSize": 20
}
Delivery format
{webhookUrl}Atlas sends a signed POST to your webhookUrl when a pay-in or pay-out reaches a final status, and when a dispute is opened or resolved.
Answer with any HTTP 2xx within 30 seconds to confirm receipt. Any other response, or a timeout, counts as a failed attempt and is retried (see Retry policy). Every event and its trigger are listed in the Event catalog.
Request format
| Property | Value |
|---|---|
| HTTP method | POST |
| Content-Type | application/json; charset=utf-8 |
| Signature header | X-Webhook-Signature |
| Signed data | Raw request body (JSON payload) |
| Signing key | Your merchant apiSecret |
| Signature encoding | Lowercase hexadecimal |
| Success criterion | HTTP 2xx response |
| Request timeout | 30 s |
POST {webhookUrl} HTTP/1.1
Content-Type: application/json; charset=utf-8
X-Webhook-Signature: <lowercase-hex HMAC-SHA256>
{ "event": "pay_in.completed", ... }
Signature verification
Every delivery carries X-Webhook-Signature: an HMAC-SHA256 of the raw request body, keyed with your apiSecret and encoded as lowercase hex. Check it on every delivery before you act on the payload.
- Keep the raw body
Read the request body as received, before any JSON parser runs. The signature covers these exact bytes.
- Compute the expected signature
Calculate HMAC-SHA256 over the raw body with your
apiSecretas the key, and encode the result as lowercase hex. - Compare in constant time
Compare your result with the
X-Webhook-Signatureheader using a constant-time comparison, such ascrypto.timingSafeEqualin Node.js. - Reject or process
If the values differ, reject the request and take no action; the Node.js receiver answers
401. If they match, parse the JSON, route oneventand answer with a 2xx.
The same receiver, with a handler for every event, is in Webhook receiver.
expected = lowercase_hex(HMAC_SHA256(apiSecret, raw_body)) valid = constant_time_equals(expected, X-Webhook-Signature)
const express = require('express'); const crypto = require('crypto'); const app = express(); app.post('/webhooks/atlas', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); const received = (req.get('X-Webhook-Signature') || '').toLowerCase(); const expected = crypto .createHmac('sha256', API_SECRET) .update(rawBody, 'utf8') .digest('hex'); const ok = received.length === expected.length && crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(received)); if (!ok) return res.status(401).end(); const payload = JSON.parse(rawBody); switch (payload.event) { case 'pay_in.completed': handlePayInCompleted(payload); break; case 'pay_in.expired': handlePayInExpired(payload); break; case 'pay_in.failed': handlePayInFailed(payload); break; case 'pay_out.completed': handlePayOutCompleted(payload); break; case 'pay_out.failed': handlePayOutFailed(payload); break; case 'pay_out.expired': handlePayOutExpired(payload); break; case 'dispute.opened': handleDisputeOpened(payload); break; case 'dispute.resolved': handleDisputeResolved(payload); break; } res.status(200).end(); }); app.listen(3000);
Retry policy
A delivery that gets a non-2xx response, or no response within 30 seconds, is retried on the schedule below. After the 10th failed attempt the delivery is marked permanently failed and surfaced to the operator.
Retry schedule
| Attempt | Delay after failure | Behavior |
|---|---|---|
| 1 | immediate | First delivery attempt. |
| 2 | +5 s | Retry. |
| 3 | +30 s | Retry. |
| 4 | +2 min | Retry. |
| 5 | +10 min | Retry. |
| 6–10 | +1 h | Retry, one hour after each failure. |
| >10 | — | Delivery marked permanently failed. |
Handle duplicates
Retries mean a webhook may be delivered more than once. Make your handler idempotent:
- Deduplicate on
(event, payInId),(event, payOutId)or(event, disputeId). If you have already processed that pair, answer 2xx and do nothing else. - Return HTTP 2xx to confirm reception. Any non-2xx response triggers a retry.
- Respond within 30 seconds and run heavy processing asynchronously.
Webhook delivery timeout: 30 s Webhook maximum attempts: 10 Window for retries: up to ~5 hours
Event catalog
The top-level event field names what happened. Pay-in and pay-out events fire on final statuses; dispute events fire when a dispute is opened and when it is resolved.
| Event | Trigger |
|---|---|
pay_in.completed | Pay-in moved to Completed. Carries geo and utr. |
pay_in.completed | Pay-in moved to Completed. Carries geo; utr is present with value null. |
pay_in.expired | Pay-in moved to Expired. |
pay_in.failed | Pay-in moved to Failed. Carries reason (string or null). Not sent when you cancel the pay-in yourself with Cancel a pay-in: the 200 response of that call is the confirmation. |
pay_out.completed | Pay-out moved to Completed. Carries geo and the recipient field recipientCbu. |
pay_out.completed | Pay-out moved to Completed. Carries geo and the recipient fields recipientPhone, recipientCedula, recipientBank, recipientAccountNumber. |
pay_out.completed | Pay-out moved to Completed. Carries geo and the recipient fields of the rail. IMPS: recipientAccountNumber, recipientBank, recipientIfsc. UPI: recipientUpiVpa (v2.6). |
pay_out.completed | Pay-out moved to Completed. Carries geo and the recipient fields recipientAccountNumber, recipientBank. |
pay_out.failed | Pay-out moved to Failed. |
pay_out.expired | Pay-out moved to Expired, including after an AwaitingFunds timeout. |
dispute.opened | Dispute created. |
dispute.resolved | Dispute resolved (any terminal state). |
Sample payloads for every event are in Payload schemas. The statuses behind these events are listed in Status reference.
Payload schemas
Every payload is a JSON object with a top-level event discriminator. Switch on event to route the message to the right handler.
A payload always carries the full key set the platform knows for that event. A recipient or reference key that does not apply to the transaction, because it belongs to another market or another rail, is still serialised with value null. The samples show only the keys that carry a value here, so read the keys you need instead of iterating over the object.
pay_in.completed carries originalAmount right after amountAdjustment: the amount you originally asked for. It equals requestedAmount unless support closed the order on a lower amount the payer actually sent. In that case amount and requestedAmount are the received amount you are credited for, amountAdjustment is 0 and isManualConfirmation is true.{
"event": "pay_in.completed",
"payInId": "1c75c312-93f3-4b34-903e-10fb38a56bec",
"displayId": "PI00000007",
"merchantOrderId": "order-12345",
"amount": 5000.00,
"requestedAmount": 5000.00,
"amountAdjustment": 0,
"originalAmount": 5000.00,
"currency": "ARS",
"amountUsdt": 4.75,
"status": "Completed",
"geo": "Argentina",
"payerName": "Juan Martinez",
"amount": 1500.00,
"requestedAmount": 1500.00,
"amountAdjustment": 0,
"originalAmount": 1500.00,
"currency": "VES",
"amountUsdt": 1.47,
"status": "Completed",
"geo": "Venezuela",
"payerName": "Luis Pérez",
"amount": 5001,
"requestedAmount": 5000,
"amountAdjustment": 1,
"originalAmount": 5000,
"currency": "INR",
"amountUsdt": 51.25,
"status": "Completed",
"geo": "India",
"payerName": "Amit Verma",
"utr": "0086543219",
"amount": 50000,
"requestedAmount": 50000,
"amountAdjustment": 0,
"originalAmount": 50000,
"currency": "KRW",
"amountUsdt": 35.97,
"status": "Completed",
"geo": "SouthKorea",
"payerName": "Park Jisoo",
"completedAt": "2026-04-14T13:13:50.651Z",
"isManualConfirmation": false
}{
"event": "pay_in.expired",
"payInId": "...",
"displayId": "PI00000008",
"merchantOrderId": "order-67890",
"amount": 5000.00,
"requestedAmount": 5000.00,
"amountAdjustment": 0,
"currency": "ARS",
"amount": 1500.00,
"requestedAmount": 1500.00,
"amountAdjustment": 0,
"currency": "VES",
"amount": 5001,
"requestedAmount": 5000,
"amountAdjustment": 1,
"currency": "INR",
"amount": 50000,
"requestedAmount": 50000,
"amountAdjustment": 0,
"currency": "KRW",
"status": "Expired",
"expiredAt": "2026-04-14T13:45:00Z"
}{
"event": "pay_in.failed",
"payInId": "3a1b2c4d-5e6f-7890-abcd-ef1234567890",
"displayId": "PI00000009",
"merchantOrderId": "order-99999",
"amount": 5000.00,
"requestedAmount": 5000.00,
"amountAdjustment": 0,
"currency": "ARS",
"amount": 1500.00,
"requestedAmount": 1500.00,
"amountAdjustment": 0,
"currency": "VES",
"amount": 5001,
"requestedAmount": 5000,
"amountAdjustment": 1,
"currency": "INR",
"amount": 50000,
"requestedAmount": 50000,
"amountAdjustment": 0,
"currency": "KRW",
"status": "Failed",
"reason": "Administrative cancellation"
}{
"event": "pay_out.completed",
"payOutId": "...",
"displayId": "PO00000002",
"merchantOrderId": "payout-123",
"amount": 5000.00,
"currency": "ARS",
"amountUsdt": 4.85,
"status": "Completed",
"geo": "Argentina",
"recipientCbu": "0000177501057000189625",
"recipientName": "Maria Garcia Lopez",
"displayId": "PO00000002",
"merchantOrderId": "payout-ve-12345",
"amount": 1500.00,
"currency": "VES",
"amountUsdt": 1.47,
"status": "Completed",
"geo": "Venezuela",
"recipientPhone": "+584121234567",
"recipientCedula": "V-12345678",
"recipientBank": "Banesco",
"recipientAccountNumber": "01020123456789012345",
"recipientName": "Carlos Rodríguez",
"displayId": "PO00000041",
"merchantOrderId": "payout-in-12345",
"amount": 10000,
"currency": "INR",
"amountUsdt": 102.50,
"status": "Completed",
"geo": "India",
"recipientAccountNumber": "40145477128",
"recipientBank": "HDFC Bank",
"recipientIfsc": "HDFC0001234",
"recipientName": "Ravi Kumar",
"displayId": "PO00000051",
"merchantOrderId": "payout-kr-12345",
"amount": 150000,
"currency": "KRW",
"amountUsdt": 107.91,
"status": "Completed",
"geo": "SouthKorea",
"recipientAccountNumber": "110-123-456789",
"recipientBank": "KB Kookmin Bank",
"recipientName": "Kim Minseo",
"completedAt": "2026-04-14T16:10:00Z"
}
UPI pay-out (v2.6). One extra key, recipientUpiVpa, is emitted immediately after recipientIfsc, and only on a UPI pay-out, so every other payload keeps exactly the key set it has today:
{
"event": "pay_out.completed",
"payOutId": "...",
"displayId": "PO00000042",
"merchantOrderId": "payout-in-upi-12345",
"amount": 10000,
"currency": "INR",
"amountUsdt": 102.50,
"status": "Completed",
"geo": "India",
"recipientUpiVpa": "ravi.kumar@ybl",
"recipientName": "Ravi Kumar",
"completedAt": "2026-09-09T16:10:00Z"
}{
"event": "pay_out.failed",
"payOutId": "...",
"displayId": "PO00000003",
"merchantOrderId": "payout-456",
"amount": 5000.00,
"currency": "ARS",
"amount": 1500.00,
"currency": "VES",
"amount": 10000,
"currency": "INR",
"amount": 150000,
"currency": "KRW",
"status": "Failed",
"reason": "Proof did not match recipient CBU"
"reason": "Proof did not match the recipient details"
}{
"event": "pay_out.expired",
"payOutId": "...",
"displayId": "PO00000004",
"merchantOrderId": "payout-789",
"amount": 5000.00,
"currency": "ARS",
"amount": 1500.00,
"currency": "VES",
"amount": 10000,
"currency": "INR",
"amount": 150000,
"currency": "KRW",
"status": "Expired",
"expiredAt": "2026-04-14T16:30:00Z"
}{
"event": "dispute.opened",
"disputeId": "...",
"displayId": "DS00000001",
"transactionType": "PayIn",
"transactionId": "...",
"merchantOrderId": "order-12345",
"amount": 5000.00,
"currency": "ARS",
"amount": 1500.00,
"currency": "VES",
"amount": 5000,
"currency": "INR",
"amount": 50000,
"currency": "KRW",
"status": "PendingEvidence",
"evidenceDeadline": "2026-04-15T15:30:00Z"
}{
"event": "dispute.resolved",
"disputeId": "...",
"displayId": "DS00000001",
"merchantOrderId": "order-12345",
"amount": 5000.00,
"currency": "ARS",
"amount": 1500.00,
"currency": "VES",
"amount": 5000,
"currency": "INR",
"amount": 50000,
"currency": "KRW",
"status": "ResolvedForTrader",
"resolution": "Evidence accepted.",
"resolvedInFavorOf": "trader",
"resolvedAt": "2026-04-14T17:00:00Z"
}Status reference
Every enum is serialised as a string in JSON responses and webhooks. The numeric values are listed for reference only.
Pay-in status
PayInStatus, returned by Get pay-in status. Each final status sends the matching webhook, except a pay-in you cancel yourself with Cancel a pay-in: it moves to Failed without a webhook.
| Value | Numeric | Description | Webhook |
|---|---|---|---|
| WaitingPayment | 1 | Awaiting payer transfer. | None |
| Completed | 2 | Payment received and reconciled. | pay_in.completed |
| Expired | 3 | Lifetime elapsed without payment. | pay_in.expired |
| Failed | 4 | Terminal failure: cancelled by Atlas, or by you from WaitingPayment or Expired with Cancel a pay-in. | pay_in.failed; none for your own cancel |
Pay-out status
PayOutStatus, returned by Get pay-out status.
| Value | Numeric | Description | Webhook |
|---|---|---|---|
| AwaitingFunds | 6 | Your pay-out balance could not cover it at creation; held up to 12 h, then released or expired. | None |
| Pending | 0 | Funded and awaiting execution. | None |
| Claimed | 1 | Execution in progress. | None |
| PendingValidation | 2 | Awaiting platform verification. | None |
| Completed | 3 | Verified and finalized. | pay_out.completed |
| Expired | 4 | Lifetime elapsed before execution. | pay_out.expired |
| Failed | 5 | Rejected at validation, or cancelled by support before the transfer. | pay_out.failed |
Dispute status
DisputeStatus, returned by Get dispute and sent in the dispute.opened and dispute.resolved webhooks.
| Value | Numeric | Description |
|---|---|---|
| PendingEvidence | 0 | Waiting for evidence from the receiving side, due within 24 h. |
| UnderReview | 1 | The receiving side has answered with evidence. Atlas is reviewing the dispute. |
| ResolvedForTrader | 2 | Resolved against you. |
| ResolvedForClient | 3 | Resolved in your favor. |
| EvidenceExpired | 4 | The receiving side sent no evidence by the deadline. Resolved in your favor automatically. |
In these values Client means you, the merchant, and Trader means the receiving side in the Atlas network, so ResolvedForTrader is a dispute resolved against you. The dispute.resolved webhook reports both outcomes in your favor as resolvedInFavorOf: "client" and an outcome against you as resolvedInFavorOf: "trader".
Dispute type and geo
| Enum | Value | Numeric |
|---|---|---|
| DisputeType | PayIn | 0 |
| DisputeType | PayOut | 1 |
| Geo | Argentina | 1 |
| Geo | Venezuela | 2 |
| Geo | India | 3 |
| Geo | SouthKorea | 4 |
Error reference
A failed request returns an HTTP error status and a JSON body with a stable error code and a human-readable message. Branch your handling on error, which is stable; message describes the failure for people and logs. One exception: Post a message or evidence answers a refused message with {"error": "<text>"} only.
Response envelope
{
"error": "<ERROR_CODE>",
"message": "<human-readable description>"
}
HTTP status codes
| Code | Condition |
|---|---|
| 200 | Successful read request, or a repeated transfer (same clientReference and amount) returning the original. |
| 201 | Successful creation, including idempotent retries. |
| 400 | Request validation or business-rule failure. |
| 401 | Authentication failure. |
| 404 | Resource not found or not owned by caller. |
| 409 | A transfer clientReference reused with a different amount (IDEMPOTENCY_KEY_MISMATCH), a pay-in cancel refused (PAYIN_COMPLETED, PAYMENT_REPORTED) or not completed (CANCEL_FAILED, safe to retry), or a request that collided with a concurrent request on the same data (CONCURRENT_UPDATE, safe to retry). |
| 5xx | Server error; retry with exponential backoff. |
Error codes
| Error code | HTTP | Condition |
|---|---|---|
MISSING_API_KEY | 401 | X-Api-Key absent. |
MISSING_SIGNATURE | 401 | X-Signature absent. |
MISSING_TIMESTAMP | 401 | X-Timestamp absent. |
INVALID_TIMESTAMP | 401 | X-Timestamp not a valid integer. |
EXPIRED_REQUEST | 401 | Timestamp outside ±300 s window. |
INVALID_API_KEY | 401 | Unknown or inactive API key. |
INVALID_SIGNATURE | 401 | HMAC mismatch. |
VALIDATION_ERROR | 400 | Generic validation failure. See message for specifics. |
AMOUNT_BELOW_MINIMUM | 400 | Request amount is below the merchant's configured minimum for pay-in or pay-out. The message includes the limit. |
AMOUNT_ABOVE_MAXIMUM | 400 | Request amount exceeds the merchant's configured maximum for pay-in or pay-out. The message includes the limit. |
INSUFFICIENT_BALANCE | 400 | Withdrawal or transfer would exceed the merchant's available USDT balance. The message includes available, required and shortfall amounts. Pay-out create never returns it: a pay-out your pay-out balance cannot cover is held as AwaitingFunds. |
NO_WALLET_ADDRESS | 400 | Withdrawal requested but no valid USDT (TRC-20) wallet address is configured for the merchant. Set it in the merchant dashboard under Settings → Settlement wallet. |
WITHDRAWAL_IN_FLIGHT | 400 | A withdrawal is already Pending or Processing; only one may be in flight at a time. |
IDEMPOTENCY_KEY_MISMATCH | 400 or 409 | Strict-idempotency merchants only (400): same merchantOrderId reused with a different request body. On POST /api/merchant/transfers (every merchant, HTTP 409): clientReference already used for a transfer of a different amount. |
CONCURRENT_UPDATE | 409 | The request collided with another request that changed the same data at the same moment (for example, several pay-outs created at once against one pay-out balance), and it was not completed. Retry it; a create retried with the same merchantOrderId, or a transfer with the same clientReference, never makes a duplicate. A request that waits for another request on the same merchant (several pay-outs created at once) gives up after about 5 seconds with this code. Post a message or evidence does not return it. Earlier this case was reported as 400 VALIDATION_ERROR. |
NO_CARD_AVAILABLE | 400 | No processing capacity for a pay-in at this moment. |
METHOD_NOT_CONTRACTED | 400 | The requested paymentMethod is not enabled for this merchant account. |
CLIENT_BLOCKED | 400 | New in v2.18. Atlas has blocked new pay-ins from this clientReference on your account. Returned by pay-in create only, for a new merchantOrderId; a repeat of an existing one still returns that pay-in. The pay-in is not created and, with checkout, there is no checkout.url. Do not retry; show the payer a neutral message such as “Payment is unavailable. Please contact the shop's support.” See Blocked clients. |
SPREAD_MISCONFIGURED | 400 | Internal rate or spread configuration error. Contact Atlas support. |
CHECKOUT_NOT_SUPPORTED | 400 | New in v2.5, scope widened in v2.6 and v2.8. checkout was requested on pay-in create outside the supported scope (geo: India on paymentMethod: "upi", "upi_intent" or "imps", served in the Atlas network only) (geo: SouthKorea on paymentMethod: "kor_bank" or "kakao_pay", served in the Atlas network only), or with expiresIn outside 300–3600s. The pay-in is not created. |
PAYIN_COMPLETED | 409 | New in v2.20. Returned by Cancel a pay-in: the pay-in is Completed, so it cannot be cancelled. Credit the order; do not retry. |
PAYMENT_REPORTED | 409 | New in v2.20. Returned by Cancel a pay-in: a payment was reported for the order (a UTR is on file, or the payer tapped "I have paid" on the hosted page) (a payer reference is on file, or the payer tapped "I have paid" on the hosted page), or a payment check or a dispute is open on it. Atlas support settles the order; it goes on normally and can still complete or expire. Do not retry. |
CANCEL_FAILED | 409 | New in v2.20. Returned by Cancel a pay-in: Atlas could not confirm the cancellation with the payment network right now. The pay-in stays WaitingPayment and can still complete or expire in the meantime. Retry after a delay. |
NOT_FOUND | 404 | Resource not found or not owned by caller. |
When to retry a request
| HTTP | Recommended client behavior |
|---|---|
| 400, 409 | Do not retry. Inspect error and message. Exceptions: NO_CARD_AVAILABLE may be retried after a short delay, and CONCURRENT_UPDATE (409) should be retried after a short delay with the same request. CANCEL_FAILED (409, pay-in cancel): retry after a delay. CLIENT_BLOCKED is not an exception: a retry gets the same answer until Atlas lifts the block. Neither are PAYIN_COMPLETED and PAYMENT_REPORTED (409) on a pay-in cancel: the order was not cancelled, and a retry does not change that. |
| 401 | Do not retry. Check your credentials and signature computation (see Authentication). |
| 404 | Do not retry. On a status lookup by merchantOrderId, 404 means the order was not created: repeat the create with the same merchantOrderId and body. |
| 5xx, timeout | Retry with exponential backoff. Reuse merchantOrderId (or a transfer's clientReference) for idempotency. To check whether a timed-out create went through, look the order up with pay-in or pay-out status by merchantOrderId. |
Endpoint map
Every merchant endpoint, grouped by area. All of them use HMAC request signing (see Authentication). Each purpose links to the full reference.
For an overview of the integration options, see the Developers hub and the H2H API guide.
Pay-ins
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/merchant/pay-in | HMAC | Create a pay-in |
| GET | /api/merchant/pay-in/{payInId}/status | HMAC | Get pay-in status |
| GET | /api/merchant/pay-in/status?merchantOrderId={merchantOrderId} | HMAC | Get pay-in status by merchantOrderId (v2.19) |
| POST | /api/merchant/pay-in/{payInId}/cancel | HMAC | Cancel a pay-in (v2.20) |
| POST | /api/merchant/pay-in/cancel?merchantOrderId={merchantOrderId} | HMAC | Cancel a pay-in by merchantOrderId (v2.20) |
| POST | /api/merchant/pay-in/{payInId}/utr | HMAC | Submit payer UTRSubmit a payer reference |
Pay-outs
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/merchant/pay-out | HMAC | Create a pay-out |
| GET | /api/merchant/pay-out/{payOutId}/status | HMAC | Get pay-out status |
| GET | /api/merchant/pay-out/status?merchantOrderId={merchantOrderId} | HMAC | Get pay-out status by merchantOrderId (v2.19) |
Reference data
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /api/merchant/banks?geo={geo} | HMAC | List banks and wallets for a geo |
| GET | /api/merchant/payment-methods?geo={geo} | HMAC | List available payment methods for a geo |
Disputes
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/merchant/disputes | HMAC | Open a dispute |
| GET | /api/merchant/disputes/{id} | HMAC | Get a dispute |
| POST | /api/merchant/disputes/{id}/messages | HMAC | Post a message or evidence |
| GET | /api/merchant/disputes/{id}/messages | HMAC | Get messages |
Finance
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /api/merchant/balance | HMAC | Get USDT balance (pay-in, pay-out, reserved) |
| POST | /api/merchant/withdrawals | HMAC | Request a withdrawal of the pay-in balance to your wallet |
| GET | /api/merchant/withdrawals/{withdrawalId} | HMAC | Get a withdrawal |
| GET | /api/merchant/withdrawals?page&pageSize&status | HMAC | List withdrawals |
| POST | /api/merchant/transfers | HMAC | Move USDT from the pay-in balance to the pay-out balance |
| GET | /api/merchant/transfers/{transferId} | HMAC | Get a transfer |
| GET | /api/merchant/transfers?page&pageSize | HMAC | List transfers |
Webhooks
Atlas also calls your server: a signed POST to your webhookUrl for every event. See Delivery format and Signature verification.
Limits
| Limit | Value |
|---|---|
| Timestamp window | ±300 s |
| Dispute reason length | ≤ 2000 characters |
| Dispute attachments per request | ≤ 10 files; files after the tenth are dropped without an error |
| Dispute attachments request size | ≤ 50 MB |
| Webhook delivery timeout | 30 s |
| Webhook maximum attempts | 10 |
Shared helpers
A reference client in Node.js 18+ that uses the built-in fetch and crypto. Every example below calls the same signing helper.
config.jsreads the API key and secret from theATLAS_API_KEYandATLAS_API_SECRETenvironment variables.sign()computes the HMAC-SHA256 oftimestamp.bodywith your secret, as described in Authentication.call()sends the request withX-Api-Key,X-TimestampandX-Signature, and throws on a non-2xx response with theerrorcode as the message (see Error reference).
// config.js const BASE_URL = 'https://api.atlas-online.io'; const API_KEY = process.env.ATLAS_API_KEY; const API_SECRET = process.env.ATLAS_API_SECRET; // client.js const crypto = require('crypto'); function sign(timestamp, body) { return crypto .createHmac('sha256', API_SECRET) .update(`${timestamp}.${body}`, 'utf8') .digest('hex'); } async function call(method, path, jsonBody) { const timestamp = String(Math.floor(Date.now() / 1000)); const body = jsonBody ? JSON.stringify(jsonBody) : ''; const signature = sign(timestamp, body); const res = await fetch(`${BASE_URL}${path}`, { method, headers: { 'X-Api-Key': API_KEY, 'X-Timestamp': timestamp, 'X-Signature': signature, 'Content-Type': 'application/json' }, body: body || undefined }); const payload = await res.json().catch(() => ({})); if (!res.ok) throw Object.assign( new Error(payload.error || 'HTTP_ERROR'), { status: res.status, body: payload }); return payload; }
Pay-in flow
Create a pay-in, then poll status as a fallback while the pay_in.completed webhook is in flight.
Confirmation does not need the payer's UTR. If the payer reports one, relay it with POST /api/merchant/pay-in/{payInId}/utr before the poll loop; it helps match the payment.
- Create the pay-in
Send
POST /api/merchant/pay-inwith the amount, a 900-secondexpiresInand yourmerchantOrderId, The example logs the identifiers and the payment details you show the payer. See Create a pay-in. - Relay the UTR
If the payer reports a reference, send it to
POST /api/merchant/pay-in/{payInId}/utr. See Submit payer UTR. - Poll as a fallback
Read the status every 30 seconds for up to 15 minutes and stop once it leaves
WaitingPayment. The webhook remains the primary signal. See Get pay-in status.
const payIn = await call('POST', '/api/merchant/pay-in', { amount: 5000.00, currency: 'ARS', geo: 1, amount: 1500.00, currency: 'VES', geo: 2, amount: 5000, currency: 'INR', geo: 3, paymentMethod: 'upi', amount: 50000, currency: 'KRW', geo: 4, paymentMethod: 'kor_bank', expiresIn: 900, merchantOrderId: 'order-12345', clientReference: 'customer-7781' }); console.log(payIn.payInId, payIn.displayId, payIn.cbu, payIn.expiresAt); console.log(payIn.payInId, payIn.displayId, payIn.recipientPhone, payIn.recipientCedula, payIn.expiresAt); console.log(payIn.payInId, payIn.displayId, payIn.upiVpa, payIn.upiIntentLink, payIn.expiresAt); // Optional: relay the UTR if the payer reports one. // The pay-in is confirmed with or without it. await call('POST', `/api/merchant/pay-in/${payIn.payInId}/utr`, { utr: '0086543219' }); console.log(payIn.payInId, payIn.displayId, payIn.accountNumber, payIn.bankName, payIn.expiresAt); // Poll fallback while waiting for the webhook const deadline = Date.now() + 15 * 60 * 1000; while (Date.now() < deadline) { await new Promise(r => setTimeout(r, 30000)); const s = await call('GET', `/api/merchant/pay-in/${payIn.payInId}/status`); if (s.status !== 'WaitingPayment') break; }
Pay-out flow
Submit a pay-out, then read its status. Treat Completed / Failed / Expired as terminal only after the webhook confirms.
- Create the pay-out
Send
POST /api/merchant/pay-outwith the recipient details, the amount, a 1800-secondexpiresInand yourmerchantOrderId. See Create a pay-out. - Read the status
Call
GET /api/merchant/pay-out/{payOutId}/status. OnCompletedthe example logscompletedAt; onFailedit logssupportNote. See Get pay-out status. - Wait for the webhook
Treat the pay-out as final when
pay_out.completed,pay_out.failedorpay_out.expiredarrives (see the Event catalog).
const payOut = await call('POST', '/api/merchant/pay-out', { recipientCbu: '0000177501057000189625', recipientName: 'Maria Garcia Lopez', amount: 5000.00, currency: 'ARS', geo: 1, recipientPhone: '+584121234567', recipientCedula: 'V-12345678', recipientBank: 'Banesco', recipientAccountNumber: '01020123456789012345', recipientName: 'Carlos Rodríguez', amount: 1500.00, currency: 'VES', geo: 2, recipientAccountNumber: '40145477128', recipientBank: 'HDFC Bank', recipientIfsc: 'HDFC0001234', recipientName: 'Ravi Kumar', amount: 10000, currency: 'INR', geo: 3, paymentMethod: 'imps', recipientAccountNumber: '110-123-456789', recipientBank: 'KB Kookmin Bank', recipientName: 'Kim Minseo', amount: 150000, currency: 'KRW', geo: 4, paymentMethod: 'kor_bank', expiresIn: 1800, merchantOrderId: 'payout-67890', clientReference: 'customer-7781' }); const status = await call( 'GET', `/api/merchant/pay-out/${payOut.payOutId}/status` ); if (status.status === 'Completed') { console.log('Settled at', status.completedAt); } else if (status.status === 'Failed') { console.log('Rejected:', status.supportNote); }
Disputes
Open a dispute, then attach evidence files via multipart. The multipart upload is signed over the encoded form body, so the client encodes the form first and then signs and sends those same bytes.
- Open the dispute
Send
POST /api/merchant/disputeswithtransactionType(0for a pay-in,1for a pay-out), the transaction id, a reason and adisputeAmount, which may be partial. See Open a dispute. - Post evidence
postDisputeMessage()sends the text asbodyand each file asfilesin a multipart form toPOST /api/merchant/disputes/{id}/messages. It encodes the form withnew Request(), signstimestamp + "."followed by those raw bytes and sends the same bytes with the generatedContent-Type(see Sign a multipart request). Attachment limits are listed in the Endpoint map. See Post a message.
const dispute = await call('POST', '/api/merchant/disputes', { transactionType: 0, // 0 = PayIn, 1 = PayOut transactionId: payInId, reason: 'Client claims payment was sent but not credited.', merchantOrderId: 'order-12345', disputeAmount: 750.00 // may be partial ≤ transaction amount }); const fs = require('fs'); const path = require('path'); async function postDisputeMessage(disputeId, text, files = []) { const form = new FormData(); form.append('body', text); for (const filePath of files.slice(0, 10)) { // files after the 10th are dropped form.append('files', new Blob([fs.readFileSync(filePath)]), path.basename(filePath)); } // Encode the form once: these bytes are both signed and sent. const url = `${BASE_URL}/api/merchant/disputes/${disputeId}/messages`; const encoded = new Request(url, { method: 'POST', body: form }); const body = Buffer.from(await encoded.arrayBuffer()); const timestamp = String(Math.floor(Date.now() / 1000)); const signature = crypto // HMAC over the raw bytes, not decoded text .createHmac('sha256', API_SECRET) .update(`${timestamp}.`) .update(body) .digest('hex'); const res = await fetch(url, { method: 'POST', headers: { 'X-Api-Key': API_KEY, 'X-Timestamp': timestamp, 'X-Signature': signature, 'Content-Type': encoded.headers.get('content-type') }, body }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }
Webhook receiver
An Express 4.x endpoint that verifies the signature and routes every event. Read the raw body before any JSON parser runs, then verify the HMAC in constant time.
- Take the raw body
express.raw({ type: 'application/json' })keeps the body as bytes, so the HMAC runs over exactly what Atlas signed. - Verify
The receiver compares the lowercase-hex HMAC-SHA256 with
X-Webhook-Signatureusingcrypto.timingSafeEqualand answers401on a mismatch. See Signature verification. - Route and acknowledge
It parses the JSON, switches on
eventwith one handler per event and answers200. See the Event catalog.
const express = require('express'); const crypto = require('crypto'); const app = express(); app.post('/webhooks/atlas', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); const received = (req.get('X-Webhook-Signature') || '').toLowerCase(); const expected = crypto .createHmac('sha256', API_SECRET) .update(rawBody, 'utf8') .digest('hex'); const ok = received.length === expected.length && crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(received)); if (!ok) return res.status(401).end(); const payload = JSON.parse(rawBody); switch (payload.event) { case 'pay_in.completed': handlePayInCompleted(payload); break; case 'pay_in.expired': handlePayInExpired(payload); break; case 'pay_in.failed': handlePayInFailed(payload); break; case 'pay_out.completed': handlePayOutCompleted(payload); break; case 'pay_out.failed': handlePayOutFailed(payload); break; case 'pay_out.expired': handlePayOutExpired(payload); break; case 'dispute.opened': handleDisputeOpened(payload); break; case 'dispute.resolved': handleDisputeResolved(payload); break; } res.status(200).end(); }); app.listen(3000);
Changelog
Release notes for merchant-facing API changes, newest first.
Every response carries the contract date in the X-Api-Version response header (see Versioning). Older versions are not removed, and additions below are backwards-compatible where noted.
v2.20 — 2026-11-04
- New: cancel a pay-in.
POST /api/merchant/pay-in/{payInId}/cancelandPOST /api/merchant/pay-in/cancel?merchantOrderId={merchantOrderId}cancel a pay-in you no longer need, for example when you sent the order to several providers and another one answered first, or you never showed the payer our payment details. No request body, so the canonical string is<X-Timestamp>.. ThemerchantOrderIdform follows the encoding rules of status by merchantOrderId. - What it does. A pay-in in
WaitingPaymentorExpiredmoves toFailedfor good: a late payment no longer completes it. The answer is 200 with the body ofGET /api/merchant/pay-in/{payInId}/status. A pay-in that is alreadyFailedgets the same 200, so the call is safe to retry. Nopay_in.failedwebhook is sent for a cancel. To take the payment again, create a new pay-in with a newmerchantOrderId. - New error codes (409).
PAYIN_COMPLETED: the pay-in is completed, credit it.PAYMENT_REPORTED: a payment was reported for the order (a UTR on file, or "I have paid" on the hosted page) (a payer reference on file, or "I have paid" on the hosted page), or a payment check or a dispute is open on it; Atlas support settles it and the order goes on normally. Do not retry either.CANCEL_FAILED: Atlas could not confirm the cancellation with the payment network right now, so the pay-in staysWaitingPaymentand can still complete or expire; retry after a delay. A missing or over-longmerchantOrderIdgets 400VALIDATION_ERROR, an unknown order 404NOT_FOUND. - Migration. None: two new endpoints and three new error codes, nothing else changes.
X-Api-Versionis now2026-11-04.
v2.19 — 2026-10-22
- New: pay-out status by
merchantOrderId.GET /api/merchant/pay-out/status?merchantOrderId={merchantOrderId}finds a pay-out by your own order id and returns the same body asGET /api/merchant/pay-out/{payOutId}/status. Use it when a create timed out and you have nopayOutId. 200 means the pay-out exists: use its status and do not create it again. 404NOT_FOUNDmeans it was not created: repeat the create with the samemerchantOrderIdand the same body, which never makes a second pay-out. - New: pay-in status by
merchantOrderId.GET /api/merchant/pay-in/status?merchantOrderId={merchantOrderId}does the same for pay-ins and returns the body ofGET /api/merchant/pay-in/{payInId}/status,payInIdincluded. - How the lookup matches. Only orders on your account are searched, and the id is matched exactly (case-sensitive, not trimmed). A first create refused with 400 created no order, so its id gives 404. A missing or blank
merchantOrderId, or one longer than 200 characters, gets 400VALIDATION_ERROR. Send the value URL-encoded as a query component (+as%2B,&as%26,#as%23,%as%25). Signing does not change: the query string is not signed, so the canonical string is<X-Timestamp>.as for everyGET. - Migration. None: two new endpoints, nothing else changes.
X-Api-Versionis now2026-10-22.
v2.18 — 2026-10-20
- New error code
CLIENT_BLOCKED(400) on pay-in create. Atlas can block new pay-ins from one of your clients. While the block is active,POST /api/merchant/pay-inwith thatclientReferenceand a newmerchantOrderIdis refused with 400CLIENT_BLOCKEDand the message "Pay-ins from this clientReference are blocked. Contact Atlas support." The pay-in is not created and no webhook is sent. The block is per merchant account and per exactclientReference. A repeat of an existingmerchantOrderIdstill returns that pay-in, and pay-outs are not affected. See Blocked clients. - Hosted and embedded checkout. A refused create has no
checkoutobject, so there is nocheckout.urlorcheckout.embedUrland no page to open or mount. Show the payer a neutral message in your own UI, for example “Payment is unavailable. Please contact the shop's support.”, and do not retry (see Hosted checkout errors). - Migration. If you branch on
error, handleCLIENT_BLOCKEDas final for that client: do not retry it and do not resend the payer under anotherclientReference. Nothing else changes: no field is added, removed or renamed.X-Api-Versionis now2026-10-20.
v2.17 update — 2026-10-18 · concurrent pay-outs
- Pay-outs created at the same moment are each decided on the current pay-out balance. When several
POST /api/merchant/pay-outrequests arrived within milliseconds and the pay-out balance covered only some of them, the others could be refused with 400VALIDATION_ERROR(“Concurrent update detected …”) and were not created. Each of them is now created with 201, exactly as if the requests had come one after another: reserved while the balance covers it, held asAwaitingFundsonce it does not. - New error code
CONCURRENT_UPDATE(409). A merchant API request that still collides with a concurrent request on the same data now gets 409CONCURRENT_UPDATEinstead of 400VALIDATION_ERROR. It was not completed: retry it after a short delay with the same body (see Error reference). A pay-out create that waits for other creates on your account gives up after about 5 seconds with this code. Post a message or evidence does not return it. - Migration. None required. If you branch on
error, treatCONCURRENT_UPDATEas retryable. No field is added, removed or renamed, andX-Api-Versionstays2026-10-16.
v2.17 — 2026-10-16
- New transfer source
autoSettle. When Atlas has set a pay-out balance top-up for your account, automatic settlement first moves that amount from the pay-in balance to the pay-out balance and requests the withdrawal for the rest (see Automatic settlement). That move is listed byGET /api/merchant/transfersandGET /api/merchant/transfers/{transferId}withsource: "autoSettle"andclientReference: null. Before this version such a move would have been reported asmanual. - Migration. If you map
sourceto a fixed set of values, addautoSettleor accept unknown values. Nothing else changes.X-Api-Versionis now2026-10-16.
v2.16 — 2026-10-12
- New: transfer from the pay-in balance to the pay-out balance.
POST /api/merchant/transferswithamountandclientReferencemoves USDT from the pay-in balance (availableBalance) to the pay-out balance (payOutBalance), the same move as the transfer in the merchant dashboard. Only the pay-in to pay-out direction is supported. The move is synchronous: the 201 response carries the transfer and your balances right after it, and there is no webhook. Pay-outs held for funds that the pay-out balance now covers are released right after the transfer. - Idempotent by
clientReference. The reference is unique per merchant. Sent again with the same amount, it returns the original transfer with 200 and moves nothing; with a different amount it is refused with 409IDEMPOTENCY_KEY_MISMATCH. An amount aboveavailableBalancegetsINSUFFICIENT_BALANCE, also when a concurrent withdrawal or transfer used the balance first. GET /api/merchant/transfers/{transferId}andGET /api/merchant/transfers(newest first, paged) list every transfer, including the ones made in the merchant dashboard (source: "manual",clientReference: null).- Migration. None: three new endpoints, nothing else changes.
X-Api-Versionis now2026-10-12. - Fixed.
POST /api/merchant/disputes/{id}/messagesnow checks that the dispute is yours before it stores any file. A dispute that belongs to another merchant or does not exist gets 404NOT_FOUND, as on the other dispute endpoints (a missing dispute used to get 400{"error": "Dispute not found"}). The server now verifies a multipart request over the raw body bytes, so a signature overtimestamp + "."and the bytes you send passes with binary files; a signature made the old way, over the body read as UTF-8 text, is still accepted. JSON requests are signed as before.POST /pay-in/{payInId}/utranswers another merchant's pay-in with 404NOT_FOUND, the same as a missing one.
v2.15 — 2026-10-06
- Exchange rate, commission and USDT credit on the pay-in.
POST /api/merchant/pay-in(the first call and every idempotent replay) andGET /pay-in/{payInId}/statusreturn three new always-present fields:exchangeRate, your exchange rate for the order, fixed when it was created, in units ofcurrencyper 1 USDT;commissionRate, your commission on the order in percent (8.5means 8.5%); andamountUsdt, the USDT credited to your balance when the order completes. - How they fit together.
requestedAmount / exchangeRateis the gross USDT value of the order.amountUsdt= gross × (1 −commissionRate/ 100), rounded to 6 decimal places. That is the amount credited, and it is the same value asamountUsdtin thepay_in.completedwebhook. The rate and the commission do not change after creation. If support closes the order on a lower received amount (v2.12),amountUsdton the status is recalculated on that amount at the same rate. TreatamountUsdtas authoritative: recomputing it from the publishedexchangeRatecan differ by 0.000001, because the gross is rounded to 6 decimal places before the commission is applied. - Migration. Nothing is removed, renamed or reordered: the three fields are appended after the existing ones (after
checkoutwhen it is present). Webhooks, pay-outs and the other endpoints are unchanged.X-Api-Versionis now2026-10-06.
v2.14 — 2026-10-01
- PhonePe and Paytm deep links on request.
POST /api/merchant/pay-inaccepts an optional booleanincludeUpiAppLinks. When it istrueand the order hasupiIntentLink(aupiorupi_intentorder served by a receiving account in the Atlas network), the response addsupiAppLinks { phonepe, paytm }right beforecheckout: links that open each app's own send-money screen with the payee and the locked payable amount filled in. A P2P transfer started this way is not declined the way aupi://payintent to a P2P VPA sometimes is. KeepupiIntentLinkand the QR on screen as the fallback. - New section Payment screen options compares the QR, the UPI ID, the UPI Intent and the app deep links, and shows the layout of our hosted page.
- Hosted and embedded checkout: one UPI layout. A
upiorder now gets the same app choice asupi_intent: PhonePe, Paytm and Other UPI, which unfolds the QR and the UPI ID. The separate UPI button (the genericupi://payintent) is gone from the page. Nothing changes in the API:upiIntentLinkis still returned as before. - Migration. None. The flag is optional and off by default, and a request without it gets a byte-identical response. On orders where it does not apply (
imps, external routing) it is ignored, never rejected. The links are not part ofGET /pay-in/{payInId}/statusor the webhooks.X-Api-Versionis now2026-10-01.
v2.13 — 2026-09-28
- Automatic settlement. A withdrawal requested for you at
accumulationThreshold(autoSettleEnabled: true) may now be sent to your wallet automatically. While the transfer is on its way the withdrawal isProcessing(the status that was reserved until now) andtxHashstaysnull; once it is confirmed on chain the withdrawal isCompletedwith itstxHash, as before. Processing→Pendingis possible. When an automatic transfer cannot be made, the withdrawal goes back toPendingand is paid by the finance team like any other. A withdrawal you request yourself (POST /api/merchant/withdrawalsor the merchant dashboard) is unchanged and never sent automatically.autoSettleEnabledis managed by the platform; it is no longer switched from the merchant dashboard.- Migration. Nothing is added, removed or renamed and no value changes meaning. Treat
Processingas “in flight” (it already blocks a new request withWITHDRAWAL_IN_FLIGHT) and keep polling untilCompletedorFailed.X-Api-Versionis now2026-09-28.
v2.12 — 2026-09-24
- New always-present field
originalAmountonGET /pay-in/{payInId}/statusand on thepay_in.completedwebhook, right afteramountAdjustment: what you originally asked for. On every ordinary order it equalsrequestedAmount. - Pay-ins closed on the amount actually received. When the payer transfers less than the order amount (you asked for 1000, the payer sent 500), support may close the order as
Completedon what was received. The order is then reported withamountandrequestedAmountequal to the received amount, the one you are credited for (amountUsdtis priced on it at the order's original rate), withamountAdjustment0,isManualConfirmationtrue, andoriginalAmountkeeping your original ask. The amount only ever goes down, and an order is corrected at most once. - Migration. Nothing is removed or renamed. Reconcile on
requestedAmountas before; iforiginalAmountdiffers from it, the order was settled on a lower received amount, so release or re-bill the difference on your side. The create response and thepay_in.expired/pay_in.failedwebhooks are unchanged.X-Api-Versionis now2026-09-24.
v2.11 update — 2026-09-29 · adjustment range per account
- The amount adjustment is configured per merchant account and is off by default. When it is enabled for your account, a UPI order whose exact amount is already open on every requisite is served within ± the range configured for your account, in its step, nearest first: for example ±2 in whole rupees (
1001,999,1002,998for a request of1000) or ±1.5 in steps of 0.5 (1000.5,999.5,1001, …).amountAdjustmentstays within ± that range, can be negative and may be fractional, andamountmay then carry paise. When it is not enabled the order gets the exact amount orNO_CARD_AVAILABLE, as before the adjustment existed. You are still credited forrequestedAmount. No field is added, removed or renamed andX-Api-Versionis unchanged.
v2.11 update — 2026-09-23 · whole rupees, either way
- The amount adjustment is now a whole number of rupees and can go either way. When the exact amount is not free on any requisite, a UPI order is served at
requestedAmount± 1 or ± 2 INR (999,1001,998,1002for a request of1000), nearest first, and the side is chosen so that over time the adjustments cancel out.amountno longer carries paise on a whole-rupee request.amountAdjustmentis therefore-2,-1,0,1or2: accept a negative value. You are still credited forrequestedAmount, whichever way the amount moved. No field is added, removed or renamed andX-Api-Versionstays2026-09-21.
v2.11 — 2026-09-21
- Two always-present fields join
amounton the pay-in create response, onGET /pay-in/{payInId}/statusand on thepay_in.completed/pay_in.expired/pay_in.failedwebhooks:requestedAmount, what you sent and what you are credited for, andamountAdjustment, which isamountminusrequestedAmount. Nothing is removed or renamed and no existing value changes meaning; reconcile your order onrequestedAmountandmerchantOrderId.X-Api-Versionis now2026-09-21. - On a UPI order (
paymentMethodupiorupi_intent) the platform may move the payable amount so that it stays unique on the requisite it is served by (since 2026-09-29: within ± the range configured for your account, when enabled, see above; from 2026-09-23: by one or two whole rupees, up or down; between 2026-09-21 and 2026-09-23 it was raised by 0.10–2.00 INR in 0.10 steps). Animpsorder is never moved: its payer types the amount into a bank app by hand.amountis what the payer has to transfer: show it exactly. You are credited forrequestedAmount. - In this market
requestedAmountalways equalsamountandamountAdjustmentis always0.
v2.10 — 2026-09-19
- The UPI link is now payee + amount + currency only.
upiIntentLinkin the pay-in create response, and the QR / “Pay in UPI app” link on the hosted and embedded checkout page, are nowupi://pay?pa=<vpa>&pn=<payee>&am=<amount, 2dp>&cu=INR. Thetnandtrparameters (both carried ourdisplayId) are removed:tron a person-to-person VPA made some payer apps treat the intent as a merchant payment and decline it. - Migration. No field added, removed or renamed, and the amount is still locked in the payer’s app. Treat
upiIntentLinkas opaque; if you parsedtn/trout of it to recover the order id, readdisplayIdfrom the same response instead. Confirmation is unchanged: UTR and exact amount.X-Api-Versionis now2026-09-19.
v2.9 — 2026-09-14
- Pay-in create response is now per-geo.
POST /api/merchant/pay-inreturns only the requisite keys that apply to the order's geo and rail. A key that does not apply is omitted. It is no longer serialized asnullor"", so the empty-string andnullplaceholders that used to travel with every order are gone.qrImageBase64andqrPayloadare present only when the requisite carries a QR. - Which requisite keys you get. An Argentina order returns
cbu. - Which requisite keys you get. A Venezuela order returns
recipientPhone+recipientCedula. - Which requisite keys you get. An India
upi/upi_intentorder returnsupiVpa+upiIntentLink; animpsorder returnsaccountNumber+ifscCode. - Which requisite keys you get. A South Korea
kor_bankorder returnsaccountNumber; akakao_payorder returnsqrPayload(+qrImageBase64). - Always present:
payInId,displayId,geo,ownerName,bankName,amount,currency,expiresAt,requiresManualConfirmation. Key order on the wire is now fixed and matches the samples in this reference: common → requisites → QR →checkout. - Migration. Treat a missing key exactly as you treated
null. No new keys, no renamed keys, no value changes: an integrator that reads keys by name and tolerates absence (every mainstream JSON library does) needs no change. Strictly-typed parsers that require every key must mark the requisite and QR keys optional.GET /pay-in/{payInId}/statusand the webhooks are unchanged.X-Api-Versionis now2026-09-14.
v2.8 — 2026-09-11
- Hosted and embedded checkout now serve South Korea.
checkoutis accepted forgeo: SouthKoreaon both Korean pay-in rails,paymentMethod: "kor_bank"and"kakao_pay". Same page, samecheckout { url, expiresAt, locale, embedUrl }object, same redirect contract, same loader and events. TheCHECKOUT_NOT_SUPPORTEDmessage now lists South Korea (paymentMethodkor_bankorkakao_pay) among the scopes a payment page is available for. - What the payer sees.
kor_bankrenders the receiving account's number, bank and account holder for a manual bank transfer, with no QR and no deep link.kakao_payrenders the receiving account's Kakao Pay QR, plus a tap-to-open button when the payload is akakaopay.comlink (a raw EMV payload is shown as a QR only). Page languages areko(default) anden;checkout.localeresolves against that set. - No payer reference on the Korean rails. Neither rail has a transaction-reference concept: the payer simply confirms they have paid and Atlas matches the deposit against the receiving account's statement. The page therefore shows no reference form, and the "I have paid" call rejects a reference sent anyway: the browser-facing
POST /api/checkout/{token}/claimanswers400 UTR_NOT_APPLICABLE. This has no effect on the HMAC surface: the optional pay-in reference endpoint is unchanged and still accepts a Korean transfer reference from you as matching context. - Amounts are whole KRW (ISO 4217 exponent 0, unchanged since v2.3, and the page formats them as
₩1,234,567with no decimal part). - No request or response shape change. No new fields, no new endpoints, no new webhook events, and the only new error code is on the browser-facing page API, not on this contract. A v2.7 integration that never sends a Korean
checkoutsees byte-identical behaviour.X-Api-Versionis now2026-09-11. Deploy prerequisite: the merchant must haveSouthKoreain its allowed geos and amerchant_method_spreadspay-in row for each Korean method it uses; a missing pay-in spread isMETHOD_NOT_CONTRACTEDand has no fallback.
v2.7 — 2026-09-10
- New: embedded checkout. The same Atlas payment page can now be rendered inside your own page instead of being navigated to; see Embedded checkout. Contract-wise this is one new field; everything else is a browser-side loader and two portal settings.
- Response:
checkoutgains a fourth memberembedUrl(https://api.atlas-online.io/embed/{token}), appended afterlocale; the existing three are never re-ordered. It is present whenever thecheckoutobject itself is present, and the wholecheckoutkey is still omitted when you did not ask for a page, so a v2.6 integration sees a byte-identical response.urlandembedUrlare alternatives, not a sequence: redirect the payer tourl, or embedembedUrl. - How to embed. Load
https://api.atlas-online.io/embed/v1/atlas.jsand callAtlas.mount('#atlas-pay', { url: checkout.embedUrl }). The loader creates one cross-origin<iframe>, keeps its height in sync with the content and raisesready,resize,claim,success,expired,failed,cancel,intent,download,focusanderrorevents. The payment flow inside the frame is the hosted page, unchanged. - A browser event is a UI hint, not a payment. Exactly as for the redirect flow, credit the order only from the signed
pay_in.completedwebhook orGET /pay-in/{payInId}/status.cancelmeans the payer asked to leave. The frame never navigates, the routing is yours. - Embedding domains are an account setting, not a contract field. Register the origins that may frame the page from your merchant portal session with
PUT /api/merchant/checkout-settings/origins(the merchant portal exposes the same setting under Settings → Embedded checkout; the theme editor is Pay Page appearance):https://only, host (+ port), apex and wildcard listed separately, maximum 10. An unregistered origin gets a readable 403 page inside the frame (anderror {code:'NO_RESPONSE'}from the loader after ~8 s), because a browser-blocked frame is otherwise undiagnosable. - Appearance (theme) is an account setting too, shared by the hosted page and the embed: schema 2 with
version,theme,variables(37 public names),rules(allow-listed selectors × properties),branding(your own logo),background(image, gradient, overlay),display(shape switches),texts(your own wording),variablesDark,layout,locale,languageSwitcher, saved in the portal and overridable per page withmount({ appearance }). Schema-1 themes keep working unchanged. - No new endpoints, error codes or webhook events on the HMAC surface.
X-Api-Versionis now2026-09-10.
v2.6 — 2026-09-09
- New: India UPI pay-outs.
POST /api/merchant/pay-outacceptspaymentMethod: "upi"together with the new optionalrecipientUpiVpa(UPI ID,handle@psp, stored lower-cased) as the whole recipient object.impsstays the default whenpaymentMethodis omitted and its rules are unchanged. Operationally the money is sent from a UPI account in the Atlas network to that VPA and the proof is uploaded; you are notified by the samepay_out.completedwebhook. - The two India pay-out rails are mutually exclusive.
recipientUpiVpaon animpspay-out ("recipientUpiVpa is only valid for paymentMethod upi") andrecipientAccountNumber/recipientBank/recipientIfscon aupipay-out ("…are not valid for India UPI pay-outs; send recipientUpiVpa only") are bothVALIDATION_ERROR; so isrecipientUpiVpaon a pay-out in any other geo ("recipientUpiVpa is only valid for India"). ApaymentMethodother thanimps/upinow returns "paymentMethod '<x>' is not available for India pay-outs; use imps or upi". The v2.2 message "UPI pay-outs are not supported for India" is gone. Whole-INR amounts unchanged. recipientUpiVpais echoed back on the pay-out create response, onGET /pay-out/{payOutId}/statusand in thepay_out.completedwebhook, immediately afterrecipientIfsc(which stays in the payload asnullon a UPI pay-out, as since v2.2). The key is omitted when null, so IMPS pay-out payloads, and pay-out payloads in every other geo, are byte-identical to v2.5.pay_out.failedandpay_out.expiredare unchanged.- New India pay-in method
upi_intent("UPI Intent", railupi).GET /payment-methods?geo=Indianow returns{ "code": "upi_intent", "name": "UPI Intent", "type": "upi" }alongsideupiandimps, and the IndiapaymentMethodon pay-in create is one ofupi|upi_intent|imps. Behaviour, UTR confirmation, spreads and webhooks are identical toupi. The two are one rail, so aupi_intentorder is served by any UPI-rail receiving account and no new payment details are needed. - Pay-in create response: new field
upiIntentLink, the UPI deep linkupi://pay?pa=…&pn=…&am=…&cu=INR&tn=<displayId>&tr=<displayId>, right afterifscCode. Present for every India pay-in on the UPI rail (upiandupi_intent), idempotent replays included; the key is omitted forimps, for orders in every other geo and for cascade-routed orders. It is not in the webhooks and not inGET /pay-in/{payInId}/status. - How to use
upiIntentLink. On Android render it as a tap target ("Pay in UPI app"). The payer's UPI app opens pre-filled with the VPA, payee name, exact amount and our reference, so nothing can be mistyped. Keep the QR (qrImageBase64/upiVpa) as the iOS/desktop fallback. The payer still confirms with a UTR exactly as forupi. - Hosted checkout scope widened from
paymentMethod == "upi"to all three India methods:upi,upi_intentandimps. One page adapts to the method:upishows the QR plus the UPI ID,upi_intentadds aupi://payapp button on phones,impsshows account number, IFSC, bank and account holder and no QR at all. TheCHECKOUT_NOT_SUPPORTEDmessage now names all three India methods. - No new webhook events and no new error codes. Additive on every other endpoint and payload.
X-Api-Versionis now2026-09-09.
v2.5 — 2026-09-07
- New: Hosted checkout. Optional
checkoutobject onPOST /api/merchant/pay-inrequests an Atlas-hosted payment page instead of embedding recipient details directly; see Hosted checkout. - MVP scope gate.
checkoutis accepted only forgeo: India+paymentMethod: "upi"routed to the Atlas network. Outside that scope the request is rejected with the new error codeCHECKOUT_NOT_SUPPORTEDand no pay-in is created. - When
checkoutis present,expiresInmust be within 300–3600 seconds. - Response gains a trailing
checkoutobject (url,expiresAt,locale), present only when checkout was requested and accepted; the key is omitted otherwise, so existing integrations see a byte-identical response. Idempotent replay of the samemerchantOrderIdreturns the samecheckout.url. - Redirect contract. The payer is returned to
returnUrlwith302 GET returnUrl?payInId=…&merchantOrderId=…for every outcome (success / not confirmed / expired / "start again"); only an explicit payer cancel goes tocancelUrl(falls back toreturnUrl). No status and no signature in the URL: the redirect is not a confirmation. Credit orders only from the signedpay_in.completedwebhook orGET /pay-in/{payInId}/status. - Expiry can move. Submitting a UTR on the hosted page may extend
expiresAtonce by up to 30 minutes, sopay_in.expiredcan arrive later than the create response implied. Do not derive expiry from the create response. - New browser-facing hosted-page endpoints (
GET /pay/{token},GET /api/checkout/{token},GET /api/checkout/{token}/status,POST /api/checkout/{token}/claim,GET /pay/{token}/return) are informational only: not HMAC-authenticated, not part of this merchant API, and not intended to be called by merchant backends. - Purely additive to every other endpoint and payload. No new webhook events.
X-Api-Versionis now2026-09-07.
v2.4 — 2026-09-02
- New: Finance API, balance and withdrawals.
GET /api/merchant/balancereturns your USDT position (pay-in balanceavailableBalance, pay-out balancepayOutBalance,reservedBalance,totalBalance), the configuredwalletAddressand the withdrawal currently in flight. POST /api/merchant/withdrawalsrequests a withdrawal of the pay-in balance to your wallet address, with an optional partialamountand the whole balance when omitted. One withdrawal at a time (WITHDRAWAL_IN_FLIGHT); the amount is deducted from the pay-in balance on request and refunded if the transfer fails.GET /api/merchant/withdrawals/{withdrawalId}andGET /api/merchant/withdrawals(paged, status filter) to trackPending → Processing → Completed | Failed. No webhook for withdrawals yet, so poll.- New error codes
NO_WALLET_ADDRESSandWITHDRAWAL_IN_FLIGHT.INSUFFICIENT_BALANCEis reused for withdrawals above the pay-in balance. - Purely additive: existing endpoints and payloads are unchanged.
X-Api-Versionis now2026-09-02.
v2.3 — 2026-08-07
- New geo: South Korea (
geo = 4/SouthKorea, currencyKRW). Pay-in rails:kor_bank(bank + account number) andkakao_pay(QR payload). Pay-outs:kor_bankonly.paymentMethod: "kakao_pay"on a pay-out returnsVALIDATION_ERROR; omitted defaults tokor_bank. paymentMethodis required on SouthKorea pay-in create (kor_bank|kakao_pay): South Korea is served by two rails, and the rail decides which receiving account serves the order.- Whole KRW only. KRW has no minor unit, so fractional amounts on SouthKorea pay-ins and pay-outs are rejected with
VALIDATION_ERROR, never rounded. - SouthKorea pay-out recipient fields.
recipientAccountNumber(5-20 digits; hyphens/spaces accepted on input, stored bare) +recipientBank. Both are existing shared columns, with no schema additions. Cross-geo fields are rejected as usual. - Pay-in create response reuses existing fields. A
kor_bankorder returnsaccountNumber+bankName+ownerName; akakao_payorder returnsqrPayload(+qrImageBase64when an image is attached). No new response fields, so existing parsers are unaffected. requiresManualConfirmationis alwaystruefor SouthKorea (Atlas confirms the payment against the payer's bank or Kakao Pay receipt). Neither rail asks the payer for a transaction reference.GET /payment-methods?geo=SouthKoreareturnskor_bankandkakao_pay;GET /banks?geo=SouthKoreareturns the KR bank catalog (KB Kookmin, Shinhan, Woori, …).
v2.2 — 2026-07-31
- New geo: India (
geo = 3/India, currencyINR). Pay-in rails:upi(VPA + QR) andimps(bank account + IFSC). Pay-outs: IMPS only.paymentMethod: "upi"on a pay-out returnsVALIDATION_ERROR; omitted defaults toimps. paymentMethodis required on India pay-in create (upi|imps): India is the first market served by two rails, and the rail decides which receiving account serves the order.- Whole INR only. Fractional amounts on India pay-ins and pay-outs are rejected with
VALIDATION_ERROR, never rounded. - New India pay-out recipient fields.
recipientAccountNumber(an existing shared column) +recipientBank+ newrecipientIfsc(11-char IFSC, validated asAAAA0XXXXXX). Cross-geo fields are rejected as usual. - Pay-in create response: new fields
upiVpa,accountNumber,ifscCode. India orders return the destination VPA (upi) or account + IFSC (imps);qrPayload/qrImageBase64carry the UPI QR when attached. Null for orders in other geos. Additive, so existing parsers are unaffected. - UTR is mandatory evidence for India. Relay the payer's UTR via
POST /pay-in/{payInId}/utr; an India pay-in cannot be confirmed without one.requiresManualConfirmationis alwaystruefor India (Atlas confirms the payment against the UTR). pay_out.completedwebhook + pay-out status: new nullablerecipientIfscfield. Additive; carries the IFSC on India pay-outs,nullelsewhere.GET /payment-methods:typeis now per-method. India returnsupiandimpsunder one geo; the values returned for other geos are unchanged.GET /banks?geo=Indiareturns[], because the IFSC fully addresses the bank.- Doc correction: the banks catalog
kindfield has always beenBank. Earlier versions of this page incorrectly showedWalletsample entries.
v2.1.1 — 2026-06-15
paymentMethodis optional again onPOST /pay-in. Reverts the v2.1 requirement. Omit it and the platform uses the geo's default rail (for Venezuela that is Pago Móvil, and any bank is a Pago Móvil bank); supply the methodcodeonly when you need a specific one. A missing value is no longer an error.
v2.1 — 2026-06-11 Breaking
paymentMethodis now required onPOST /pay-inand is the methodcode. Send the payment methodcodefor the geo (for example0102= Banco de Venezuela,0108= Provincial), the same vocabulary as the pay-outrecipientBank. The previously-optional rail name (cvuven_bank) is no longer the value. A missing value returns400 VALIDATION_ERROR; an unknown or disabled code returnsMETHOD_NOT_CONTRACTED.GET /api/merchant/payment-methodsnow returnscode. Each item is{ code, name, type }; pass thecodeaspaymentMethodon pay-in create.typeremains the provider rail, for reference only.
v2.0.1 — 2026-06-10
- Consistent validation error shape. A missing required field on
POST /pay-inandPOST /pay-outnow always returns the same envelope:{"error":"VALIDATION_ERROR","message":"<field> is required"}, using the lowercase JSON field name. Previously a missingclientReferenceon a pay-in could return a different shape with the capitalized property name. The request field names are unchanged: always send the lowercase names shown in §3 and §4.
v2.0 — 2026-06-04 Breaking
- clientReference required on creates. Add
clientReference(your stable end-customer identifier,[A-Za-z0-9._:-], max 64 chars) to everyPOST /pay-inandPOST /pay-outrequest. Missing or invalid values return400 VALIDATION_ERROR. - disputeAmount required on dispute create. Add
disputeAmount(the contested amount in transaction currency; may be partial, must be ≤ transaction amount) to everyPOST /disputesrequest. - Venezuela pay-out: new required field
recipientAccountNumber. AddrecipientAccountNumber(bank account number / número de cuenta) alongside the existingrecipientPhone,recipientCedula, andrecipientBankfor Venezuela pay-outs. - Per-geo recipient enforcement. Send only the recipient fields that belong to the geo. A field that does not belong to the geo is rejected with
400 VALIDATION_ERROR:recipientCbu, for instance, is accepted only on an Argentina pay-out:recipientPhoneandrecipientCedula, for instance, are accepted only on a Venezuela pay-out. - New endpoint
GET /api/merchant/payment-methods?geo={geo}. Returns available payment rails ({ name, type }) for the geo. Thetypevalue maps directly to the new optionalpaymentMethodfield on pay-in create. - AwaitingFunds pay-out status. A pay-out submitted before your balance is funded is now accepted (
201) and held inAwaitingFundsstatus for up to ~12 h, then settled or auto-expired (pay_out.expired). Treat201as "accepted, not yet settled" and wait for the webhook. - Pay-in status echoes
clientReferenceandqrPayload. GET pay-in status now returnsclientReference(echo) andqrPayload. - Pay-out status echoes
clientReference. GET pay-out status now returnsclientReferenceandgeo, plus all four Venezuela recipient fields. - Webhook updates.
pay_in.completedaddsgeoandutr;pay_out.completedaddsgeoand the Venezuela recipient fields; newpay_in.failedevent withreason. - New error codes.
METHOD_NOT_CONTRACTED(requested paymentMethod not enabled for this merchant) andSPREAD_MISCONFIGURED(internal rate configuration error) added to the documented catalog. - X-Api-Version response header. Every response now carries
X-Api-Version: 2026-06-04.
v1.2 — 2026-05-13
- New error codes for amount limits on pay-in and pay-out creation:
AMOUNT_BELOW_MINIMUMandAMOUNT_ABOVE_MAXIMUM. Each response carries the configured limit in themessagefield. - New error code
INSUFFICIENT_BALANCEon pay-out creation (HTTP 400). The response includes the available balance and the shortfall, so the caller can surface the value to the user directly. - New error code
IDEMPOTENCY_KEY_MISMATCHfor accounts opted into strict idempotency. A repeatedmerchantOrderIdsent with a different body is rejected explicitly instead of replaying the first response. - Argentina CBU format is now validated: 22 digits with the standard checksum. Invalid values are rejected with
VALIDATION_ERROR. GET /api/merchant/banks?geo=Venezuelareturns a structured catalog. Each entry has a stablecode(for example"0134"for Banesco),name, andkind(BankorWallet). HMAC-authenticated like the rest of/api/merchant/*.pay_in.completedwebhook payload includes a new booleanisManualConfirmation. It istruewhen the pay-in was confirmed manually after its TTL elapsed.- The
utrfield is validated for length (max 128 characters) at the API layer. - Empty or null
currencyis rejected withVALIDATION_ERROR. - Invalid
geovalues now return a dedicated enum error, separate from the "geo not enabled for this account" rule.