ATLAS Docs

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.

  1. Get your credentials

    You need an apiKey, an apiSecret and a webhookUrl registered for your account. See Credentials.

  2. Sign every request

    Send X-Api-Key, X-Timestamp and X-Signature on each call. See Authentication.

  3. Create a pay-in

    Call POST /api/merchant/pay-in from 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 with POST /api/merchant/pay-in/{payInId}/utr. If the call times out, look the order up by your merchantOrderId with GET /api/merchant/pay-in/status before you send it again.

  4. Credit the order from the webhook

    Verify the signature of pay_in.completed (see Signature verification) and credit the order. Poll GET /pay-in/{payInId}/status as a fallback.

Environment and formats

PropertyValue
Merchant dashboard (web application)https://portal.atlas-online.io
API base URLProduction: https://api.atlas-online.io
Sandbox: https://sandbox.api.atlas-online.io
API prefix/api/merchant
ProtocolHTTPS (TLS 1.2+)
Request formatapplication/json (UTF-8); multipart/form-data for dispute attachments
Response formatapplication/json (UTF-8)
IdentifiersUUID v4 (string, hyphenated)
TimestampsISO 8601, UTC (2026-04-14T15:30:00Z)
Monetary valuesDecimal 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.
AuthenticationHMAC-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 valueCurrency
Argentina1ARS
Venezuela2VES
India3INR
SouthKorea4KRW

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

FeatureIn your market
Payment methodsCVU 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 recipientArgentina 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 confirmationArgentina 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 checkoutAvailable. See Hosted checkout and Embedded checkout.
Bank catalogAvailable. See List banks.
Recipient fields belong to one market. Send only the fields that belong to the geo. Cross-geo fields are rejected with 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.

PathWho renders the payment screenDocs
Payment link (hosted checkout)Atlas. You redirect the payer to checkout.url.Hosted checkout · Payment link on our site
Embedded checkoutAtlas, inside a frame on your page. You mount checkout.embedUrl.Embedded checkout · Embedded checkout on our site
H2H APIYou. 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

CredentialDescription
apiKeyPublic identifier, format mk_<48-hex>.
apiSecret64-hex secret. Signs your requests and verifies our webhooks. Store it in a secure secret vault.
webhookUrlPublicly reachable HTTPS endpoint that accepts POST and returns HTTP 2xx.
Rotation. Request credential rotation via the Atlas account manager. Atlas issues a new 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.

Base https://api.atlas-online.io/api/merchant
Authentication. HMAC-SHA256 with three required headers.
Endpoint map
# 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

HeaderDescription
X-Api-KeyMerchant public identifier.
X-TimestampUnix timestamp in seconds (UTC) at request time. Must be within ±300 seconds of server time.
X-SignatureLowercase hexadecimal HMAC-SHA256 signature of the canonical string (see below).
Content-Typeapplication/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).

  1. Take the timestamp

    Use the current Unix time in seconds. It goes into X-Timestamp and into the canonical string.

  2. Serialize the body once

    Produce the exact bytes you are going to send: the JSON text, or the encoded multipart body. For GET requests use an empty string.

  3. Build the canonical string

    Join the timestamp, a literal . and the body: timestamp + "." + raw_request_body.

  4. Compute the signature

    Run HMAC-SHA256 over the canonical string with your apiSecret as the key and hex-encode the result in lowercase.

  5. Send the headers

    Put the result in X-Signature and send it with X-Api-Key and X-Timestamp.

Exact body. The signed body must be byte-identical to the transmitted body. Do not reserialize or reorder JSON keys between signing and sending.

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.

  1. Encode the form yourself before signing, so the boundary is already fixed. A client that builds the form while sending (fetch with a FormData body, curl -F) chooses the boundary after you have signed.
  2. 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.
  3. 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

  1. All three headers are present and non-empty.
  2. X-Timestamp is a valid integer and within ±300 seconds of server clock.
  3. X-Api-Key resolves to an active merchant account.
  4. Computed HMAC-SHA256 matches X-Signature (constant-time comparison).

Authentication errors

Every failure returns 401 with one of these codes in error.

HTTPError codeCondition
401MISSING_API_KEYX-Api-Key header absent.
401MISSING_SIGNATUREX-Signature header absent.
401MISSING_TIMESTAMPX-Timestamp header absent.
401INVALID_TIMESTAMPX-Timestamp is not a valid integer.
401EXPIRED_REQUESTTimestamp outside ±300 s window.
401INVALID_API_KEYUnknown or inactive API key.
401INVALID_SIGNATURESignature verification failed.

Common mistakes

MistakeWhat 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 apiSecret must 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.
Canonical string
// 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
Signing
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"
Required headers
X-Api-Key: mk_a838ef5b55d93add046ec9ea426420fef3dca5557d58ebd1
X-Timestamp: 1776172416
X-Signature: 9f2a7e4b5c8d1a3e0f6b2d9e7c4a5b8f1d2e3c4a5b6d7e8f9a0b1c2d3e4f5a6b
Content-Type: application/json

Create a pay-in

POST/api/merchant/pay-in

A 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

NameTypeRequiredDescription
amountdecimalRequiredAmount 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.
currencystringRequiredISO 4217 code (ARS) (VES) (INR) (KRW).
geointeger | stringRequiredRegion code or enum name. Must match a region enabled for your merchant account.
expiresInintegerRequiredLifetime of the pay-in, in seconds.
merchantOrderIdstringRequiredYour 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.
clientReferencestringRequiredYour 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.
paymentMethodstringOptionalPayment 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.
paymentMethodstringOptionalPayment 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.
paymentMethodstringRequiredPayment 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.
paymentMethodstringRequiredPayment 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.
checkoutobjectOptionalNew 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.
includeUpiAppLinksbooleanOptionalNew 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.
checkoutobjectOptionalNew 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

Per-market response (v2.9). The response carries only the payment-detail keys that apply to the order's market and rail. A key that does not apply is omitted, never 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".
FieldTypeDescription
payInIdUUIDPlatform identifier. Use it for status queries and every later call.
displayIdstringHuman-readable identifier (PI00000001).
geostringRegion the pay-in was issued for.
ownerNamestringAccount holder name to show the payer.
bankNamestringDestination bank or payment provider.
cbustringDestination CBU/CVU.
cbustringPresent 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.
recipientPhonestringDestination phone for the Pago Móvil transfer.
recipientCedulastringDestination ID (cédula).
upiVpastringDestination UPI VPA (for example payer@axl), the text fallback next to the QR. Present only on upi and upi_intent orders.
accountNumberstringDestination bank account. Present only on imps orders.
ifscCodestringDestination IFSC branch code. Present only on imps orders.
upiIntentLinkstringNew 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.
accountNumberstringDestination bank account. Present only on kor_bank orders.
amountdecimalWhat the payer has to transfer. Show this value to the payer.
requestedAmountdecimalNew in v2.11. What you asked for, and what you are credited for. Equal to amount unless the payable amount was moved. Always present.
amountAdjustmentdecimalNew 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.
currencystringCurrency code.
expiresAtdatetimeExpiration timestamp.
requiresManualConfirmationbooleantrue 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.
qrImageBase64stringQR image as a data URI (data:image/png;base64,...). Present only when the payment details include a QR.
qrPayloadstringRaw host-to-host QR payload (EMV / upi://pay) for rendering the QR yourself. Present only when the payment details include a QR.
upiAppLinksobjectNew 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.
checkoutobjectNew 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.
exchangeRatedecimalNew 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.
commissionRatedecimalNew in v2.15. Your commission on this order, in percent (8.5 means 8.5%). Always present.
amountUsdtdecimalNew 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.

MethodKeys to showWhat the payer does
CVU/CBU transfercbu, ownerName, bankName, amount; qrImageBase64 or qrPayload when presentSends a bank transfer to the CBU/CVU, or scans the QR.
MethodKeys to showWhat the payer does
Pago MóvilrecipientPhone, recipientCedula, bankName, ownerName, amountSends a Pago Móvil payment to the phone, cédula and bank.
Pago Móvil, external providerAs above, plus cbu when presentThe provider issued a destination account number (número de cuenta) alongside the Pago Móvil details.
MethodKeys to showWhat the payer does
UPI (upi, upi_intent)qrImageBase64 or qrPayload, upiVpa, upiIntentLink, upiAppLinks when requested, amountScans the QR, taps a deep link, or types the VPA into a UPI app.
IMPS (imps)accountNumber, ifscCode, bankName, ownerName, amountSends 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.

MethodKeys to showWhat the payer does
Bank transfer (kor_bank)accountNumber, bankName, ownerName, amountSends a bank transfer to the account.
Kakao Pay (kakao_pay)qrImageBase64 or qrPayload, ownerName, amountScans the Kakao Pay QR.

Errors

HTTP statusError codeMessage
400VALIDATION_ERRORValidation failed (body, currency, geo, merchant status, exchange rate).
400NO_CARD_AVAILABLENo processing capacity is available right now. Retry after a short delay.
400AMOUNT_BELOW_MINIMUMThe amount is below your configured minimum.
400AMOUNT_ABOVE_MAXIMUMThe amount exceeds your configured maximum.
400METHOD_NOT_CONTRACTEDThe requested paymentMethod is not enabled for your account.
400CLIENT_BLOCKEDNew 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.
400CHECKOUT_NOT_SUPPORTEDNew 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.
400CHECKOUT_NOT_SUPPORTEDNew 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.
401See AuthenticationAuthentication 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 merchantOrderId that 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 clientReference for the same payer: clientReference must 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

Request body
{
  "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"
}
paymentMethod is optional. When omitted, the platform uses the market's default rail.paymentMethod is optional. When omitted, the platform uses the market's default rail, and any bank is Pago Móvil.paymentMethod is required (upi | upi_intent | imps), and amounts are whole INR.paymentMethod is required (kor_bank | kakao_pay), and amounts are whole KRW. Send the method code from GET /api/merchant/payment-methods?geo={geo}. clientReference is required on every request.
Response samples
{
  "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

GET/api/merchant/pay-in/{payInId}/status

Returns 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

NameTypeRequiredDescription
payInIdUUIDRequiredIdentifier returned by Create a pay-in.

Response

Response fields · 200 OK

FieldTypeDescription
payInIdUUIDPlatform identifier of the pay-in.
displayIdstringHuman-readable identifier.
statusenumWaitingPayment, Completed, Expired, Failed. See Statuses.
amountdecimalWhat the payer has to transfer.
requestedAmountdecimalNew in v2.11. What you asked for, and what you are credited for. Equal to amount unless the payable amount was moved.
amountAdjustmentdecimalNew 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.
originalAmountdecimalNew 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.
currencystringCurrency code.
createdAtdatetimeCreation timestamp.
expiresAtdatetimeExpiration timestamp.
completedAtdatetime | nullSet when status = Completed.
expiredAtdatetime | nullSet when status = Expired.
payerNamestring | nullReported payer name, filled in after Completed.
geostringRegion the pay-in belongs to.
isManualConfirmationbooleanThe 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.
requiresManualConfirmationbooleantrue 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.
utrstring | nullUnique Transaction Reference, when set. See Submit UTR.
qrPayloadstring | nullRaw host-to-host QR payload (EMV / upi://pay), or null.
clientReferencestring | nullEcho of the clientReference sent on creation.
exchangeRatedecimalNew 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.
commissionRatedecimalNew in v2.15. Your commission on this order, in percent (8.5 means 8.5%).
amountUsdtdecimalNew 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

StatusNumericDescription
WaitingPayment1Initial state. The destination is issued and the transfer is awaited.
Completed2Payment detected and reconciled. Triggers pay_in.completed.
Expired3The lifetime elapsed without payment. Triggers pay_in.expired.
Failed4Terminal 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 statusError codeMessage
404NOT_FOUND"PayIn not found": the pay-in does not exist or does not belong to your account.
401See AuthenticationAuthentication failed.

Every error code is listed in the error reference.

See also

Response samples
{
  "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

GET/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:

  1. Look the order up

    Call this endpoint with the merchantOrderId of the create that got no response.

  2. 200: the pay-in exists

    Atlas received the create. Use the returned status and payInId, and do not create the pay-in again under a new merchantOrderId.

  3. 404: the pay-in was not created

    Repeat POST /api/merchant/pay-in with the same merchantOrderId and the same body. Create is idempotent by merchantOrderId: 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

NameTypeRequiredDescription
merchantOrderIdstringRequiredThe 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:

CharacterSend asSent raw
+%2BRead as a space.
&%26Starts a new parameter, so the id is cut short.
#%23Starts the fragment. The client does not send the rest of the URL.
%%25Read 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.

Authentication. Standard merchant HMAC headers (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 statusError codeMessage
400VALIDATION_ERROR"merchantOrderId is required": the parameter is missing or blank. "merchantOrderId exceeds 200 character limit": the value is longer than 200 characters.
404NOT_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.
401See AuthenticationAuthentication failed.

Every error code is listed in the error reference.

See also

Look up a pay-in after a timeout
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()
Response samples
{
  "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

POST/api/merchant/pay-in/{payInId}/cancel
POST/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.

  1. Cancel the order

    Call this endpoint with the payInId, or with the merchantOrderId you sent to Create a pay-in.

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

  3. 409: not cancelled

    PAYIN_COMPLETED means the payment was received: credit the order as usual. PAYMENT_REPORTED means 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_FAILED means 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 statusResult
WaitingPaymentCancelled: 200, status: Failed. Refused with PAYMENT_REPORTED if a payment was reported for it (see Errors).
ExpiredCancelled the same way. An expired pay-in can otherwise still be completed through manual confirmation; after a cancel it cannot.
Failed200 with the same body, whatever made it fail. Nothing changes, so the call is safe to retry.
CompletedRefused with 409 PAYIN_COMPLETED.

Request

No request body. Send the POST with an empty body.

Path parameters

NameTypeRequiredDescription
payInIdUUIDRequired in the first formIdentifier returned by Create a pay-in.

Query parameters

NameTypeRequiredDescription
merchantOrderIdstringRequired in the second formThe 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.
Authentication. Standard merchant HMAC headers (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 statusError codeMessage
409PAYIN_COMPLETED"{displayId} is completed — a completed pay-in cannot be cancelled.": the payment was received. Credit the order. Do not retry.
409PAYMENT_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.
409CANCEL_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.
409CONCURRENT_UPDATEThe request collided with another change to the same order and was not completed. Retry after a short delay.
400VALIDATION_ERRORSecond form only. "merchantOrderId is required": the parameter is missing or blank. "merchantOrderId exceeds 200 character limit": the value is longer than 200 characters.
404NOT_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.
401See AuthenticationAuthentication failed.

Every error code is listed in the error reference.

See also

Cancel a pay-in you no longer need
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"
Response samples
{
  "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

POST/api/merchant/pay-in/{payInId}/utr

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

The UTR is optional. An India pay-in is confirmed when the payment reaches the receiving account, with or without a UTR. This call does not confirm the pay-in or change its status: it stores the UTR as matching context, and only while the pay-in is WaitingPayment or Expired. In any other status the call stores nothing and returns the current state.

Request

Path parameters

NameTypeRequiredDescription
payInIdUUIDRequiredIdentifier of the pay-in, from Create a pay-in.

Body parameters

NameTypeRequiredDescription
utrstringRequiredTransfer reference reported by the payer, as issued by their bank. Trimmed; up to 128 characters.

Response

Response fields · 200 OK

FieldTypeDescription
payInIdUUIDPlatform identifier of the pay-in.
displayIdstringHuman-readable identifier.
statusenumCurrent pay-in status. See Get pay-in status.
utrstringEcho of the recorded UTRreference.
requiresManualConfirmationbooleanThe 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

Request body
{ "utr": "0086543219" }
Response · 200 OK
{
  "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

MethodKeysWhat to render
upi, upi_intentqrImageBase64, qrPayloadThe QR. Use the image as is, or draw your own QR from the payload.
upi, upi_intentupiVpa, amountThe UPI ID and the amount as text with copy buttons.
upi, upi_intentupiIntentLinkA "Pay in UPI app" button. Present on every UPI-rail order served by a receiving account in the Atlas network.
upi, upi_intentupiAppLinks.phonepe, upiAppLinks.paytmPhonePe and Paytm buttons. Only when you sent includeUpiAppLinks: true.
impsaccountNumber, ifscCode, bankName, ownerName, amountBank 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.

OptionWhat the payer doesResponse fieldsWhere it works
QR codeScans the QR with any UPI app and confirms the pre-filled payment.qrImageBase64, qrPayloadEverywhere. On a desktop it is the main option: the payer scans the screen with a phone.
UPI IDCopies the VPA and the amount and types them into their UPI app by hand.upiVpa, amountEverywhere. The slowest option and the only one where the payer can mistype the amount, so keep it as the last fallback.
UPI IntentTaps 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.

Keep a fallback on the screen. The PhonePe and Paytm routes are app screens, not public APIs of those apps. An app update can move them, and a payer may not have the app installed. In both cases the tap does nothing visible, and a web page has no reliable way to detect it. Always keep the QR and the UPI ID reachable next to the app buttons. Like 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

NameTypeRequiredDescription
includeUpiAppLinksbooleanOptionalSend "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:

  1. PhonePe → upiAppLinks.phonepe
  2. Paytm → upiAppLinks.paytm
  3. 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

Request body
Response sample
Rendering the choices
<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>
Skip a button whose link is missing. Read keys by name: when upiAppLinks is absent, show the QR and the UPI ID open.

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

  1. Create the pay-in with a checkout object

    Send POST /api/merchant/pay-in from your server, signed as usual. checkout.returnUrl is the only required member. The request samples on the right show one body per method.

  2. Send the payer to checkout.url

    Redirect or link to it. To keep the payer on your own page, mount checkout.embedUrl instead (see Embedded checkout).

  3. Handle the return

    Every outcome lands on returnUrl with payInId and merchantOrderId in the query string. Look up the status there; the redirect itself proves nothing.

  4. Credit the order on the webhook

    Fulfil on the signed pay_in.completed webhook 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.

MethodWhat the page shows
upiA choice of PhonePe, Paytm and Other UPI, which unfolds the QR and the UPI ID. See Payment screen options.
upi_intentThe same choice as upi: PhonePe, Paytm and Other UPI, which unfolds the QR and the UPI ID.
impsAccount number, IFSC, bank and account holder. No QR and no deep link.
kor_bankAccount number, bank and account holder. No QR and no deep link.
kakao_payThe 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.

Scope gate. When 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

NameTypeRequiredDescription
returnUrlstringRequired if checkout presentAbsolute 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.
cancelUrlstringOptionalSame validation rules as returnUrl. Used only when the payer cancels explicitly on the hosted page. Falls back to returnUrl when omitted.
localestringOptionalBCP-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

FieldTypeDescription
urlstringThe 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.
expiresAtdatetimeSnapshot taken at creation time. It is not the source of truth; see the expiry note below.
localestringThe locale served on the hosted page, after the geo-default fallback.
embedUrlstringAdded 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

A redirect is not a confirmation. The browser redirect to 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

OutcomeDestinationNotes
Success (Atlas confirmed the payment)returnUrl302 GET returnUrl?payInId=…&merchantOrderId=…. Confirm the actual status through the webhook or GET status before crediting.
Not confirmed / "start again"returnUrlSame query string. You decide whether to offer a new pay-in.
ExpiredreturnUrlSame query string. The URL does not distinguish expiry; call GET status for the reason.
Explicit payer cancelcancelUrl (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

Expiry can be extended once. When the payer submits a UTR on the hosted page, 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.

Prefer it inside your own page? The same pay-in also returns 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

CodeHTTP statusWhenWhat to do
CHECKOUT_NOT_SUPPORTED400The 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_SUPPORTED400The 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_SUPPORTED400The 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_BLOCKED400New 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.

MethodPathPurpose
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}/statusPolled 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|cancelIssues the 302 back to the merchant's returnUrl/cancelUrl.
Additive to Create a pay-in. Same endpoint, same auth. Only the optional checkout request and response objects are new.
Request body: hosted checkout
{
  "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"
  }
}
Response samples
{
  "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`."
}
On the 400 message. CHECKOUT_NOT_SUPPORTED covers two cases. The sample shows the routing case, whose message is the same everywhere. For a request outside the supported scope, the server answers with one sentence that enumerates every market and paymentMethod that checkout accepts today, so this page does not quote it.

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.

The 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

  1. Create the pay-in on your server

    Exactly as for the hosted page: POST /api/merchant/pay-in with a checkout object (returnUrl required; cancelUrl and locale optional). The same scope gate applies: India (upi / upi_intent / imps)South Korea (kor_bank / kakao_pay), orders served in the Atlas network, and expiresIn within 300–3600 s.

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

  3. Put checkout.embedUrl into the page

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

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

Minimal mount
<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.

OptionDefaultDescription
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'.
languageSwitcherfalseShow 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

CallWhat 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.iframeThe <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

EventPayloadWhen
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.
What events never carry. The UTR, the payee's VPA or account number, the beneficiary name, USDT amounts or fees.The payee's account number or QR payload, the beneficiary name, USDT amounts or fees. Every script on your page can read a browser event (analytics, chat widgets, extensions), so the sensitive half of the flow never leaves the frame. All of it is in the webhook, which is signed.

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.

RuleDetail
Schemehttps:// only (http://localhost is accepted on a Development stand).
ShapeScheme and host, optionally :port. No path, no query, nothing after the host or port: https://shop.example, https://checkout.shop.example:8443.
Wildcardshttps://*.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.
Maximum10 entries. Order is kept as you typed it; duplicates are dropped.
Register the apex and the wildcard. Missing one of them is the most common integration mistake. When an unregistered origin frames the page, the payer sees a readable 403 notice inside the frame that names the domain to add, and the loader raises 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 }).

MemberDescription
versionSchema 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.
themePreset 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).
variablesThe 37 public design tokens listed below.
rulesTargeted 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.
backgroundNew in schema 2. An image, a gradient, or a tint over an image; see Background.
displayNew in schema 2. Shape switches: you pick a word and the page draws it; see Display.
textsNew in schema 2. Your own wording for fifteen strings, per language; see Texts.
variablesDarkNew 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=.
languageSwitcherShow 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.

MemberRule
imageAbsolute 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.
sizecover (default) | contain | auto.
positioncenter (default) | top | bottom | left | right.
repeatno-repeat (default) | repeat.
overlayA 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.
The background image is the one exception. It is the only merchant value that enters our CSS as something other than a colour or a number. That is why the rules above are narrow, and why a URL that breaks them is rejected on save instead of escaped. The image's origin (scheme, host and port, never the path) is added to the payment page's 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.

NameValuesNotes
buttonStylegradient | solid | outlineThe CTA's fill.
buttonTextTransformnone | uppercase
buttonWeight600 | 700 | 800Default 700.
buttonShadowon | off
stepsStyledots | hiddenThe Pay / Confirm / Done strip.
timerStylecapsule | plain | hiddenhidden hides the capsule, not the countdown: the remaining time still reaches the payer and the screen reader. A theme cannot take the deadline away.
headerAligncenter | left
qrCornersquare | roundedDefault rounded.
borderWidth0 | 1 | 2Default 1.
groundPatternnone | gridHosted page only; an embed has no ground of its own.
cardBorderon | offDefault 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.

Why the list is short. Every key above is a button or a line about your shop. The page's other strings state facts about the payment (that a link has expired, that a transfer must not be sent twice, what the recipient's requisites are), and we have to answer for those. A key outside the list is refused with 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.

NameAtlas defaultWhere it shows
Base
colorBackground#F8FAFCPage / embed ground
colorSurface#FFFFFFThe card
colorSurfaceMuted#F1F5F9Tiles, language pill
colorSurfaceHover#E2E8F0Hover states
colorBorder#E2E8F0Dividers, field borders
colorBorderStrong#CBD5E1Chips, disabled
Primary
colorPrimary#4F46E5CTA, timer ring, focus, active step
colorPrimaryHover#4338CACTA hover. Derived from colorPrimary when not set
colorPrimaryAlt#6366F1Second stop of the CTA gradient. Derived from colorPrimary when not set
colorPrimarySoft#EEF2FFTints, active-step halo. Derived from colorPrimary when not set
colorPrimaryBorder#C7D2FEAccent block borders. Derived from colorPrimary when not set
colorOnPrimary#FFFFFFText on the CTA
Text
colorText#0F172ABody text, the amount
colorTextSecondary#64748BCaptions
colorTextMuted#94A3B8Decorative glyphs
colorLabel#64748BUppercase section labels
Status
colorSuccess / colorSuccessBg / colorSuccessBorder#047857 / #ECFDF5 / #A7F3D0Success
colorWarning / colorWarningBg / colorWarningBorder#B45309 / #FFFBEB / #FDE68AGrace period, timer thresholds
colorDanger / colorDangerBg / colorDangerBorder#B91C1C / #FEF2F2 / #FECACAExpired / failed
colorInfo / colorInfoBg / colorInfoBorder#1D4ED8 / #EFF6FF / #BFDBFEHints (for example the IMPS note)
colorInfo / colorInfoBg / colorInfoBorder#1D4ED8 / #EFF6FF / #BFDBFEHints (for example the bank-transfer note)
Shape
borderRadiusSm6pxSmall controls
borderRadiusMd10pxFields
borderRadiusLg14pxTiles
borderRadiusXl18pxThe card
borderRadiusFull9999pxPill buttons, chips
shadowCardsmCard shadow. Enum none | sm | md | lg, never raw CSS
Type
fontFamilyDisplayDM SansAmounts, timer, buttons
fontFamilyBodyDM SansBody text (Hindi falls back to Noto Sans Devanagari)
fontFamilyBodyDM SansBody text (a ko page falls back to Apple SD Gothic Neo / Malgun Gothic / Noto Sans KR)
fontFamilyMonoJetBrains MonoVPA, UTR, account number
fontFamilyMonoJetBrains MonoAccount number
The primary family is derived. Set 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.

Two bounded rules that are not bugs. .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

ValueRule
ColourHex 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 / padding0–48px (the px suffix is optional; borderRadiusFull also accepts 9999px).
ShadowEnum none | sm | md | lg. A raw box-shadow is not accepted.
fontWeightEnum 400 | 500 | 600 | 700 | 800.
textTransformEnum none | uppercase | capitalize.
Font familyA 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 overridePlain 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 keyIgnored, reported as a warning on save and as error {code:'THEME_UNKNOWN_KEY'} in the browser.
A Google font is an opt-in trade-off per theme. Choose one and the payment page gains 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

Only you can embed your window. The 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).
Why there is no free-form CSS. The frame is on our origin, so your stylesheet cannot reach into it. That isolation is the point: your page cannot read or alter the payee's VPA, QR or the payer's UTRthe payee's account number or QR, and neither can an XSS on your site or a third-party chat widget. In exchange you get a declarative, validated schema. Embedded payment surfaces follow the same norm: Stripe's Appearance API, Adyen's Drop-in customisation and Braintree's Drop-in all expose a bounded set of variables and rules for the same reason. If a token you need is missing, ask us; the list grows.
If you have a Content-Security-Policy. Add our origin to these two directives and nothing else. 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 integrity on /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>.js with that integrity value and crossorigin="anonymous", and follow our deprecation window.
  • Do not restrict clipboard-write for our frame in Permissions-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-write for our frame in Permissions-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 raises error {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.com link dies silently there: the payer taps Open Kakao Pay and nothing happens, with no error anywhere. The loader detects it and raises error {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

SymptomCauseFix
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.
CodeWhereEffect
NESTED_FRAMEerror event, from the loaderThe frame is nested inside another iframe or an in-app WebView. Redirect instead.
NO_RESPONSEerror event, from the loaderRaised after about 8 seconds, for example when an unregistered origin frames the page.
HANDSHAKE_FAILEDerror event, from the loaderThe 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_FAILEDerror event, from the loaderA 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_CHANNELerror event, from the loaderThe browser has no MessageChannel, so the frame cannot be embedded. Redirect to checkout.url instead.
INVALID_LINKerror event, forwarded from the frameThe 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_KEYerror event, forwarded from the frameThe unknown key is ignored. A save reports it as a warning.
THEME_CONTRAST_LOWTheme save, warningA text/background pair is below WCAG AA 4.5:1. The theme still saves.
THEME_CONTRAST_UNREADABLETheme save, refusalThe main text pair is below 3:1. The save is refused.
THEME_INVALID_URLTheme save, refusalA background.image or branding.logoUrl breaks the URL rules.
THEME_FORBIDDEN_TEXTTheme save, refusalA 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_VERSIONTheme save, refusalThe save is refused; the response lists every problem.
ORIGIN_WILDCARD_WITHOUT_APEX:<origin>Origins save, warningA 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.

MethodPathPurpose
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.jsThe loader. Mutable, max-age=300. Use this one.
GET/embed/v1/atlas.{hash}.jsThe 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.

Additive to Hosted checkout. Same request, same auth. The response's checkout object also carries embedUrl.
Request body: embedded checkout
{
  "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"
  }
}
Response: the 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..."
  }
}
Mount it on your page
<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>
Live re-theme, no reload
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
Pinning the loader (SRI)
// 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

POST/api/merchant/pay-out

Sends 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

NameTypeRequiredDescription
recipientNamestringRequiredRecipient full name as registered in the bank.
amountdecimalRequiredPayment 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.
currencystringRequiredISO 4217 code (ARSVESINRKRW).
geointeger | stringRequiredRegion code or enum name.
expiresInintegerRequiredLifetime of the pay-out in seconds.
merchantOrderIdstringRequiredIdempotency key unique to the merchant.
clientReferencestringRequiredYour stable identifier for the recipient end customer. Pattern: [A-Za-z0-9._:-], max 64 chars.
paymentMethodstringNot used in ArgentinaEvery Argentina pay-out goes to a CBU, so leave the field out. A value you send is ignored.
paymentMethodstringOptionalExplicit pay-out rail. Ignored for Venezuela pay-outs.
paymentMethodstringOptionalExplicit 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".
paymentMethodstringOptionalExplicit 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.

NameTypeRequiredDescription
recipientCbustringRequiredDestination 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.

NameTypeRequiredDescription
recipientPhonestringRequiredPhone number associated with the destination wallet (e.g. Pago Móvil).
recipientCedulastringRequiredRecipient's national ID (cédula).
recipientBankstringRequiredDestination 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.
recipientAccountNumberstringRequiredBank 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.

NameTypeRequiredDescription
recipientAccountNumberstringRequired on IMPSIMPS beneficiary account number, 5-20 digits. Hyphens and spaces are accepted and stored bare. Do not send on the UPI rail.
recipientBankstringRequired on IMPSBeneficiary bank name (e.g. HDFC Bank). Do not send on the UPI rail.
recipientIfscstringRequired on IMPSIFSC 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.

NameTypeRequiredDescription
recipientUpiVpastringRequired on UPINew 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.

NameTypeRequiredDescription
recipientAccountNumberstringRequiredBank account number, 5-20 digits. Hyphens and spaces are accepted and stored bare.
recipientBankstringRequiredDestination bank (e.g. KB Kookmin Bank). Codes and names come from List banks.

Response fields 201 Created

FieldTypeDescription
payOutIdUUIDPlatform identifier. Use it with Get pay-out status.
displayIdstringHuman-readable identifier (PO00000001).
amountFiatdecimalAmount in the declared currency.
amountUsdtdecimalPay-out amount in USDT at the creation-time rate, without the pay-out commission.
currencystringCurrency code.
expiresAtdatetimeExpiration timestamp.
recipientUpiVpastringNew 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

HTTPErrorMessage
400VALIDATION_ERRORValidation failure (body, currency, geo, recipient fields, merchant status).
400AMOUNT_BELOW_MINIMUMAmount is below the merchant's configured minimum.
400AMOUNT_ABOVE_MAXIMUMAmount exceeds the merchant's configured maximum.
400IDEMPOTENCY_KEY_MISMATCHAccounts 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.
400SPREAD_MISCONFIGUREDInternal rate configuration error. Contact Atlas support.
409CONCURRENT_UPDATEThe 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.
401See AuthenticationAuthentication 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.

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

Request
{
  "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"
}
Response
{
  "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

GET/api/merchant/pay-out/{payOutId}/status

Returns 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

NameTypeRequiredDescription
payOutIdUUIDRequiredIdentifier returned by Create a pay-out.

Response fields 200 OK

FieldTypeDescription
displayIdstringHuman-readable identifier.
statusenumPending, Claimed, PendingValidation, Completed, Expired, Failed, AwaitingFunds. See Status reference.
amountFiatdecimalAmount in the declared currency.
currencystringCurrency code.
amountUsdtdecimalEquivalent USDT amount.
geostringRegion the pay-out belongs to.
recipientNamestringRecipient name, as sent on creation.
clientReferencestring | nullEcho of the clientReference sent on creation.
createdAtdatetimeWhen the pay-out was created.
expiresAtdatetimeExpiration timestamp.
completedAtdatetime | nullNon-null when status = Completed.
expiredAtdatetime | nullNon-null when status = Expired.
supportNotestring | nullPlatform note attached at validation or rejection.

Recipient fields

FieldTypeDescription
recipientCbustring | nullDestination CBU, as sent on creation.
FieldTypeDescription
recipientPhonestring | nullDestination wallet phone number, as sent on creation.
recipientCedulastring | nullRecipient's national ID (cédula), as sent on creation.
recipientBankstring | nullDestination bank or payment provider, as sent on creation.
recipientAccountNumberstring | nullBank account number (número de cuenta), as sent on creation.
FieldTypeDescription
recipientBankstring | nullBeneficiary bank name. Set on the imps rail; null on the UPI rail.
recipientAccountNumberstring | nullIMPS beneficiary account number. Set on the imps rail; null on the UPI rail.
recipientIfscstring | nullIFSC branch code. Set on the imps rail; null on the UPI rail.
recipientUpiVpastringNew 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.
FieldTypeDescription
recipientBankstring | nullDestination bank, as sent on creation.
recipientAccountNumberstring | nullkor_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.

StatusNumericDescription
AwaitingFunds6A 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.
Pending0Funded and awaiting execution.
Claimed1Execution in progress.
PendingValidation2Transfer executed; awaiting platform verification.
Completed3Verified and finalized. Triggers pay_out.completed.
Expired4Lifetime elapsed before execution. Triggers pay_out.expired.
Failed5Rejected 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

AwaitingFunds: pay-outs your pay-out balance cannot cover yet. Such a pay-out still returns 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.

Final states. Completed, Failed and Expired are terminal. Treat them as final only after you have received the matching webhook and verified its signature.
Response · 200 OK
{
  "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

GET/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:

  1. Look the order up

    Call this endpoint with the merchantOrderId of the create that got no response.

  2. 200: the pay-out exists

    Atlas received the create. Use the returned status and do not create the pay-out again. Keep reading the status here, or wait for the webhooks as for any other pay-out.

  3. 404: the pay-out was not created

    Repeat POST /api/merchant/pay-out with the same merchantOrderId and the same body. Create is idempotent by merchantOrderId: 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

NameTypeRequiredDescription
merchantOrderIdstringRequiredThe 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:

CharacterSend asSent raw
+%2BRead as a space.
&%26Starts a new parameter, so the id is cut short.
#%23Starts the fragment. The client does not send the rest of the URL.
%%25Read 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.

Authentication. Standard merchant HMAC headers (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 statusError codeMessage
400VALIDATION_ERROR"merchantOrderId is required": the parameter is missing or blank. "merchantOrderId exceeds 200 character limit": the value is longer than 200 characters.
404NOT_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.
401See AuthenticationAuthentication failed.

Every error code is listed in the error reference.

See also

Look up a pay-out after a timeout
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()
Response samples
{
  "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

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

NameTypeRequiredDescription
geoenumRequiredPass 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:

FieldTypeDescription
codestring4-digit SUDEBAN code (e.g. "0134" Banesco).
codestringStable constant, stored lowercase (e.g. "kb_kookmin_bank").
namestringDisplay name. Suitable to render verbatim in a dropdown.
kindstringAlways 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

The list can be empty. When no banks are configured for your market the call returns 200 with []. 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.
Authentication. Standard merchant HMAC headers (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.
Stable codes. The 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.

Response · 200 OK
[
  { "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" }
]
Response · 200 OK
[
  { "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

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

NameTypeRequiredDescription
geoenumRequiredPass ArgentinaVenezuelaIndiaSouthKorea. An unrecognised value returns VALIDATION_ERROR.

Response fields 200 OK

An array of entries:

FieldTypeDescription
codestringMethod 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.
namestringHuman-readable method name (e.g. "Banco de Venezuela") (e.g. "UPI", "UPI Intent") (e.g. "Kakao Pay"). Suitable to render verbatim in a dropdown.
typestringRail identifier: the geo-level rail. Value: cvu.
typestringRail identifier: the geo-level rail. Values: ven_bank, ven_bank_account.
typestringRail 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.
typestringRail 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

Authentication. Standard merchant HMAC headers (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.
Merchant-scoped. The response reflects the payment methods contracted for this specific merchant account, not a global catalog. The list may differ between merchants.

Product overview: CVU/CBU, Argentina: Pago Móvil, Venezuela: UPI, IMPS, India: Kakao Pay, Korean bank transfer, South Korea.

Response
[
  { "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

POST/api/merchant/disputes

Contests 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

  1. Open the dispute

    Send POST /api/merchant/disputes for a completed pay-in or pay-out. The dispute starts in PendingEvidence, and its evidenceDeadline is 24 h after creation. The platform sends a dispute.opened webhook (see Webhook events).

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

  3. Review

    The receiving side in the Atlas network owes evidence within 24 h. Once it answers, the dispute moves to UnderReview and Atlas reviews it. If the deadline passes without its evidence, the dispute ends as EvidenceExpired, in your favor.

  4. Resolution

    The dispute ends in ResolvedForTrader, ResolvedForClient or EvidenceExpired, and the platform sends a dispute.resolved webhook. Check the outcome with Get a dispute.

Statuses

StatusNumeric valueMeaning
PendingEvidence0Waiting for evidence from the receiving side, due within 24 h. Your own messages and files are optional.
UnderReview1The receiving side has answered with evidence. Atlas is reviewing the dispute.
ResolvedForTrader2Resolved against you.
ResolvedForClient3Resolved in your favor.
EvidenceExpired4The 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

NameTypeRequiredDescription
transactionTypeinteger | stringRequired0 or PayIn for a pay-in; 1 or PayOut for a pay-out.
transactionIdUUIDRequiredIdentifier of the disputed pay-in or pay-out.
reasonstringRequiredYour reason for the dispute, up to 2000 characters.
merchantOrderIdstringRequiredIdempotency key, unique per merchant.
disputeAmountdecimalRequiredThe contested amount in the transaction currency. May be partial (0 < disputeAmount ≤ transaction amount).

Response fields 201 Created

FieldTypeDescription
disputeIdUUIDPlatform identifier. Use it as id in the other dispute calls.
displayIdstringHuman-readable identifier (DS00000001).
statusenumAlways PendingEvidence on creation.
evidenceDeadlinedatetime24 h after creation.

Errors

HTTP statusError codeWhen it happens
400VALIDATION_ERRORdisputeAmount is missing, zero or negative: "disputeAmount must be greater than zero".
400VALIDATION_ERRORdisputeAmount is larger than the transaction amount; unknown or foreign transaction; an active dispute already exists; the merchant is inactive.
401See AuthenticationAuthentication failure.
Request
{
  "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
}
Response · 201 Created
{
  "disputeId": "770e8400-e29b-41d4-a716-446655440222",
  "displayId": "DS00000001",
  "status": "PendingEvidence",
  "evidenceDeadline": "2026-04-15T15:30:00Z"
}

Get a dispute

GET/api/merchant/disputes/{id}

Returns the dispute with its full merchant-visible message thread.

Path parameters

NameTypeRequiredDescription
idUUIDRequiredDispute identifier.

Response 200 OK

The sample on the right shows the full shape. status takes the values listed in Dispute lifecycle.

Errors

HTTP statusError codeWhen it happens
404NOT_FOUNDThe dispute does not exist or belongs to another merchant.
401See AuthenticationAuthentication failure.
Thread visibility. The merchant sees only merchantThread. Messages exchanged between the platform operator and the counterparty are not exposed.
Response · 200 OK
{
  "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

POST/api/merchant/disputes/{id}/messages

Posts 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

NameTypeRequiredDescription
idUUIDRequiredDispute identifier.

Form fields

NameTypeRequiredDescription
bodystringRequiredMessage text.
filesfile[]OptionalUp 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

LimitValue
Max files per request10. Files after the tenth are dropped and the call still returns 200; post them in another message.
Max combined request size50 MB. A larger request is refused with 413 and no JSON body.
File type and size per fileNot 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 statusBodyWhen it happens
404NOT_FOUNDThe 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.
400Validation problem document (title, status, errors)The body form field is missing or empty, or id is not a UUID.
413No JSON bodyThe request is larger than 50 MB.
401See AuthenticationAuthentication failure. These keep the usual {"error": "<CODE>", "message": "<text>"} shape.
Signing. Sign the raw bytes of the whole multipart body exactly as you send it, boundaries and file bytes included, without decoding them as text, as described in Sign a multipart request. Build the body before you sign it, because the boundary is part of the signed bytes.
Response · 200 OK
{ "message": "Message sent" }

List dispute messages

GET/api/merchant/disputes/{id}/messages

Returns the merchant-visible message thread of the dispute, with any attachments uploaded by the merchant or added by the platform.

Path parameters

NameTypeRequiredDescription
idUUIDRequiredDispute 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 statusError codeWhen it happens
404NOT_FOUNDThe dispute does not exist or belongs to another merchant.
401See AuthenticationAuthentication failure.
Response · 200 OK
{
  "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

GET/api/merchant/balance

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

BalanceFieldWhat it holds
Pay-in balanceavailableBalanceNet proceeds of completed pay-ins. This is the amount you can withdraw.
Pay-out balancepayOutBalanceFunds that back your pay-out reservations.
ReservedreservedBalanceUSDT locked by pay-outs that are still in flight.
TotaltotalBalanceavailableBalance + payOutBalance + reservedBalance.

How money moves

  1. Pay-ins credit the pay-in balance

    The net proceeds of every completed pay-in land in your pay-in balance.

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

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

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

FieldTypeDescription
currencystringAlways USDT.
availableBalancedecimalThe pay-in balance. Net proceeds of completed pay-ins and the amount you can withdraw.
payOutBalancedecimalThe 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.
reservedBalancedecimalUSDT locked by pay-outs that are still in flight.
totalBalancedecimalavailableBalance + payOutBalance + reservedBalance.
walletAddressstring | nullThe 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.
autoSettleEnabledbooleanWhen 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.
accumulationThresholddecimalAuto-settle trigger level, in USDT.
pendingWithdrawalobject | nullThe 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 statusError codeWhen it happens
401See AuthenticationThe 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.

Request
GET /api/merchant/balance
Response · 200 OK
{
  "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

POST/api/merchant/withdrawals

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

NameTypeRequiredDescription
amountdecimalOptionalUSDT to withdraw, > 0 and ≤ availableBalance. Omit the field, or send no body at all, to withdraw the whole available balance.

Response fields 201 Created

FieldTypeDescription
withdrawalIdUUIDUse it with Get a withdrawal.
amountdecimalUSDT withdrawn.
currencystringAlways USDT.
walletAddressstringDestination address, as it was at request time.
statusenumPending, Processing, Completed or Failed.
txHashstring | nullOn-chain (TRON) transaction id of the transfer. Set when the withdrawal is Completed; still null while it is Processing.
failureReasonstring | nullSet when status = Failed.
createdAtdatetimeWhen the withdrawal was created.
submittedAtdatetime | nullWhen the transfer was broadcast. Set while Processing for an automatic transfer.
confirmedAtdatetime | nullNon-null when status = Completed.

Errors

HTTP statusError codeWhen it happens
400VALIDATION_ERRORamount is present but not greater than zero.
400NO_WALLET_ADDRESSNo valid USDT (TRC-20) wallet address is set for the merchant. Set it in the merchant dashboard (Settings → Settlement wallet) first.
400INSUFFICIENT_BALANCEThe pay-in balance is empty, or amount exceeds availableBalance. The message includes the available, required and shortfall amounts.
400WITHDRAWAL_IN_FLIGHTA 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.

One withdrawal at a time. While a withdrawal is 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.

Request
{
  "amount": 100.00
}
Response · 201 Created
{
  "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
}
Response · 400 Bad Request
{
  "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

GET/api/merchant/withdrawals/{withdrawalId}

Returns the current state of one withdrawal.

Path parameters

NameTypeRequiredDescription
withdrawalIdUUIDRequiredIdentifier returned by Request a withdrawal.

Response fields 200 OK

Same shape as the Request a withdrawal response.

Errors

HTTP statusError codeWhen it happens
404NOT_FOUNDThe id does not exist or belongs to another merchant.
401See AuthenticationThe request signature or API key was rejected.
Poll, no webhook. Withdrawal status changes are not pushed by webhook yet, so poll this endpoint. The finance team makes payouts during working hours, so a poll every few minutes is plenty.
Request
GET /api/merchant/withdrawals/6f1c2a0e-9b7d-4e21-8c3a-2f4b5d6e7a80
Response · 200 OK
{
  "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

GET/api/merchant/withdrawals

Returns your withdrawals, newest first, with an optional status filter and paging.

Query parameters

NameTypeRequiredDescription
pageintegerOptional1-based page number. Default 1.
pageSizeintegerOptionalItems per page, 1–100. Default 20.
statusenumOptionalReturn only withdrawals in this status: Pending, Processing, Completed or Failed.

Response fields 200 OK

FieldTypeDescription
itemsarrayWithdrawal objects, same shape as Get a withdrawal.
totalCountintegerTotal matching withdrawals.
pageintegerPage number of this response.
pageSizeintegerItems per page in this response.

Errors

HTTP statusError codeWhen it happens
401See AuthenticationThe request signature or API key was rejected.
Request
GET /api/merchant/withdrawals?page=1&pageSize=20&status=Completed
Response · 200 OK
{
  "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

POST/api/merchant/transfers

Moves 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

NameTypeRequiredDescription
amountdecimalRequiredUSDT to move, > 0, at most 6 decimal places and ≤ availableBalance.
clientReferencestringRequiredYour 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.

FieldTypeDescription
transferIdUUIDUse it with Get a transfer.
clientReferencestring | nullYour reference. null for a transfer made in the merchant dashboard.
amountdecimalUSDT moved.
currencystringAlways USDT.
directionstringAlways payInToPayOut (pay-in balance to pay-out balance).
sourcestringapi 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.
createdAtdatetimeWhen the transfer was booked.
balancesobjectYour 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 statusError codeWhen it happens
400VALIDATION_ERRORamount 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.
400INSUFFICIENT_BALANCEamount exceeds availableBalance, including when a concurrent withdrawal or transfer used the balance first. The message includes the available, required and shortfall amounts.
409IDEMPOTENCY_KEY_MISMATCHclientReference was already used for a transfer of a different amount. Nothing moved.
401See AuthenticationThe 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.

Snapshot, not live. 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.
Request
{
  "amount": 250.00,
  "clientReference": "fund-2026-10-12-001"
}
Response · 201 Created
{
  "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
  }
}
Response · 409 Conflict
{
  "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

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

NameTypeRequiredDescription
transferIdUUIDRequiredIdentifier returned by Create a transfer or listed by List transfers.

Response fields 200 OK

Same shape as the Create a transfer response.

Errors

HTTP statusError codeWhen it happens
404NOT_FOUNDThe id does not exist or belongs to another merchant.
401See AuthenticationThe request signature or API key was rejected.
Request
GET /api/merchant/transfers/3b9e7c51-0d2a-4f68-9a1e-5c7d2b8f4e10
Response · 200 OK
{
  "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

GET/api/merchant/transfers

Returns 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

NameTypeRequiredDescription
pageintegerOptional1-based page number. Default 1.
pageSizeintegerOptionalItems per page, 1–100. Default 20.

Response fields 200 OK

FieldTypeDescription
itemsarrayTransfer objects, same shape as Get a transfer.
totalCountintegerTotal transfers.
pageintegerPage number of this response.
pageSizeintegerItems per page in this response.

Errors

HTTP statusError codeWhen it happens
401See AuthenticationThe request signature or API key was rejected.
Request
GET /api/merchant/transfers?page=1&pageSize=20
Response · 200 OK
{
  "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

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

PropertyValue
HTTP methodPOST
Content-Typeapplication/json; charset=utf-8
Signature headerX-Webhook-Signature
Signed dataRaw request body (JSON payload)
Signing keyYour merchant apiSecret
Signature encodingLowercase hexadecimal
Success criterionHTTP 2xx response
Request timeout30 s
No webhook for transfers. A transfer to the pay-out balance is synchronous: its response is final and no webhook follows.
Delivery envelope
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.

  1. Keep the raw body

    Read the request body as received, before any JSON parser runs. The signature covers these exact bytes.

  2. Compute the expected signature

    Calculate HMAC-SHA256 over the raw body with your apiSecret as the key, and encode the result as lowercase hex.

  3. Compare in constant time

    Compare your result with the X-Webhook-Signature header using a constant-time comparison, such as crypto.timingSafeEqual in Node.js.

  4. 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 on event and answer with a 2xx.

Verify before parsing. Reject webhooks with an invalid signature and do not perform any business action based on unverified payloads.

The same receiver, with a handler for every event, is in Webhook receiver.

Verify a webhook
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

AttemptDelay after failureBehavior
1immediateFirst delivery attempt.
2+5 sRetry.
3+30 sRetry.
4+2 minRetry.
5+10 minRetry.
6–10+1 hRetry, 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.
Limits
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.

EventTrigger
pay_in.completedPay-in moved to Completed. Carries geo and utr.
pay_in.completedPay-in moved to Completed. Carries geo; utr is present with value null.
pay_in.expiredPay-in moved to Expired.
pay_in.failedPay-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.completedPay-out moved to Completed. Carries geo and the recipient field recipientCbu.
pay_out.completedPay-out moved to Completed. Carries geo and the recipient fields recipientPhone, recipientCedula, recipientBank, recipientAccountNumber.
pay_out.completedPay-out moved to Completed. Carries geo and the recipient fields of the rail. IMPS: recipientAccountNumber, recipientBank, recipientIfsc. UPI: recipientUpiVpa (v2.6).
pay_out.completedPay-out moved to Completed. Carries geo and the recipient fields recipientAccountNumber, recipientBank.
pay_out.failedPay-out moved to Failed.
pay_out.expiredPay-out moved to Expired, including after an AwaitingFunds timeout.
dispute.openedDispute created.
dispute.resolvedDispute 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.

Closed on a lower amount (v2.12). 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.
Pay-in events
{
  "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"
}
Pay-out events
{
  "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"
}
Dispute events
{
  "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.

ValueNumericDescriptionWebhook
WaitingPayment1Awaiting payer transfer.None
Completed2Payment received and reconciled.pay_in.completed
Expired3Lifetime elapsed without payment.pay_in.expired
Failed4Terminal 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.

ValueNumericDescriptionWebhook
AwaitingFunds6Your pay-out balance could not cover it at creation; held up to 12 h, then released or expired.None
Pending0Funded and awaiting execution.None
Claimed1Execution in progress.None
PendingValidation2Awaiting platform verification.None
Completed3Verified and finalized.pay_out.completed
Expired4Lifetime elapsed before execution.pay_out.expired
Failed5Rejected 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.

ValueNumericDescription
PendingEvidence0Waiting for evidence from the receiving side, due within 24 h.
UnderReview1The receiving side has answered with evidence. Atlas is reviewing the dispute.
ResolvedForTrader2Resolved against you.
ResolvedForClient3Resolved in your favor.
EvidenceExpired4The 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

EnumValueNumeric
DisputeTypePayIn0
DisputeTypePayOut1
GeoArgentina1
GeoVenezuela2
GeoIndia3
GeoSouthKorea4

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

CodeCondition
200Successful read request, or a repeated transfer (same clientReference and amount) returning the original.
201Successful creation, including idempotent retries.
400Request validation or business-rule failure.
401Authentication failure.
404Resource not found or not owned by caller.
409A 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).
5xxServer error; retry with exponential backoff.

Error codes

Error codeHTTPCondition
MISSING_API_KEY401X-Api-Key absent.
MISSING_SIGNATURE401X-Signature absent.
MISSING_TIMESTAMP401X-Timestamp absent.
INVALID_TIMESTAMP401X-Timestamp not a valid integer.
EXPIRED_REQUEST401Timestamp outside ±300 s window.
INVALID_API_KEY401Unknown or inactive API key.
INVALID_SIGNATURE401HMAC mismatch.
VALIDATION_ERROR400Generic validation failure. See message for specifics.
AMOUNT_BELOW_MINIMUM400Request amount is below the merchant's configured minimum for pay-in or pay-out. The message includes the limit.
AMOUNT_ABOVE_MAXIMUM400Request amount exceeds the merchant's configured maximum for pay-in or pay-out. The message includes the limit.
INSUFFICIENT_BALANCE400Withdrawal 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_ADDRESS400Withdrawal 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_FLIGHT400A withdrawal is already Pending or Processing; only one may be in flight at a time.
IDEMPOTENCY_KEY_MISMATCH400 or 409Strict-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_UPDATE409The 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_AVAILABLE400No processing capacity for a pay-in at this moment.
METHOD_NOT_CONTRACTED400The requested paymentMethod is not enabled for this merchant account.
CLIENT_BLOCKED400New 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_MISCONFIGURED400Internal rate or spread configuration error. Contact Atlas support.
CHECKOUT_NOT_SUPPORTED400New 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_COMPLETED409New 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_REPORTED409New 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_FAILED409New 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_FOUND404Resource not found or not owned by caller.

When to retry a request

HTTPRecommended client behavior
400, 409Do 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.
401Do not retry. Check your credentials and signature computation (see Authentication).
404Do 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, timeoutRetry 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

MethodPathAuthPurpose
POST/api/merchant/pay-inHMACCreate a pay-in
GET/api/merchant/pay-in/{payInId}/statusHMACGet pay-in status
GET/api/merchant/pay-in/status?merchantOrderId={merchantOrderId}HMACGet pay-in status by merchantOrderId (v2.19)
POST/api/merchant/pay-in/{payInId}/cancelHMACCancel a pay-in (v2.20)
POST/api/merchant/pay-in/cancel?merchantOrderId={merchantOrderId}HMACCancel a pay-in by merchantOrderId (v2.20)
POST/api/merchant/pay-in/{payInId}/utrHMACSubmit payer UTRSubmit a payer reference

Pay-outs

MethodPathAuthPurpose
POST/api/merchant/pay-outHMACCreate a pay-out
GET/api/merchant/pay-out/{payOutId}/statusHMACGet pay-out status
GET/api/merchant/pay-out/status?merchantOrderId={merchantOrderId}HMACGet pay-out status by merchantOrderId (v2.19)

Reference data

MethodPathAuthPurpose
GET/api/merchant/banks?geo={geo}HMACList banks and wallets for a geo
GET/api/merchant/payment-methods?geo={geo}HMACList available payment methods for a geo

Disputes

MethodPathAuthPurpose
POST/api/merchant/disputesHMACOpen a dispute
GET/api/merchant/disputes/{id}HMACGet a dispute
POST/api/merchant/disputes/{id}/messagesHMACPost a message or evidence
GET/api/merchant/disputes/{id}/messagesHMACGet messages

Finance

MethodPathAuthPurpose
GET/api/merchant/balanceHMACGet USDT balance (pay-in, pay-out, reserved)
POST/api/merchant/withdrawalsHMACRequest a withdrawal of the pay-in balance to your wallet
GET/api/merchant/withdrawals/{withdrawalId}HMACGet a withdrawal
GET/api/merchant/withdrawals?page&pageSize&statusHMACList withdrawals
POST/api/merchant/transfersHMACMove USDT from the pay-in balance to the pay-out balance
GET/api/merchant/transfers/{transferId}HMACGet a transfer
GET/api/merchant/transfers?page&pageSizeHMACList transfers

Webhooks

Atlas also calls your server: a signed POST to your webhookUrl for every event. See Delivery format and Signature verification.

Limits

LimitValue
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 timeout30 s
Webhook maximum attempts10

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.js reads the API key and secret from the ATLAS_API_KEY and ATLAS_API_SECRET environment variables.
  • sign() computes the HMAC-SHA256 of timestamp.body with your secret, as described in Authentication.
  • call() sends the request with X-Api-Key, X-Timestamp and X-Signature, and throws on a non-2xx response with the error code as the message (see Error reference).
config.js / client.js
// 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.

  1. Create the pay-in

    Send POST /api/merchant/pay-in with the amount, a 900-second expiresIn and your merchantOrderId, The example logs the identifiers and the payment details you show the payer. See Create a pay-in.

  2. Relay the UTR

    If the payer reports a reference, send it to POST /api/merchant/pay-in/{payInId}/utr. See Submit payer UTR.

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

Create + poll
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.

  1. Create the pay-out

    Send POST /api/merchant/pay-out with the recipient details, the amount, a 1800-second expiresIn and your merchantOrderId. See Create a pay-out.

  2. Read the status

    Call GET /api/merchant/pay-out/{payOutId}/status. On Completed the example logs completedAt; on Failed it logs supportNote. See Get pay-out status.

  3. Wait for the webhook

    Treat the pay-out as final when pay_out.completed, pay_out.failed or pay_out.expired arrives (see the Event catalog).

Create + status
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.

  1. Open the dispute

    Send POST /api/merchant/disputes with transactionType (0 for a pay-in, 1 for a pay-out), the transaction id, a reason and a disputeAmount, which may be partial. See Open a dispute.

  2. Post evidence

    postDisputeMessage() sends the text as body and each file as files in a multipart form to POST /api/merchant/disputes/{id}/messages. It encodes the form with new Request(), signs timestamp + "." followed by those raw bytes and sends the same bytes with the generated Content-Type (see Sign a multipart request). Attachment limits are listed in the Endpoint map. See Post a message.

Open + post evidence
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.

  1. Take the raw body

    express.raw({ type: 'application/json' }) keeps the body as bytes, so the HMAC runs over exactly what Atlas signed.

  2. Verify

    The receiver compares the lowercase-hex HMAC-SHA256 with X-Webhook-Signature using crypto.timingSafeEqual and answers 401 on a mismatch. See Signature verification.

  3. Route and acknowledge

    It parses the JSON, switches on event with one handler per event and answers 200. See the Event catalog.

Express receiver
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}/cancel and POST /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>.. The merchantOrderId form follows the encoding rules of status by merchantOrderId.
  • What it does. A pay-in in WaitingPayment or Expired moves to Failed for good: a late payment no longer completes it. The answer is 200 with the body of GET /api/merchant/pay-in/{payInId}/status. A pay-in that is already Failed gets the same 200, so the call is safe to retry. No pay_in.failed webhook is sent for a cancel. To take the payment again, create a new pay-in with a new merchantOrderId.
  • 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 stays WaitingPayment and can still complete or expire; retry after a delay. A missing or over-long merchantOrderId gets 400 VALIDATION_ERROR, an unknown order 404 NOT_FOUND.
  • Migration. None: two new endpoints and three new error codes, nothing else changes. X-Api-Version is now 2026-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 as GET /api/merchant/pay-out/{payOutId}/status. Use it when a create timed out and you have no payOutId. 200 means the pay-out exists: use its status and do not create it again. 404 NOT_FOUND means it was not created: repeat the create with the same merchantOrderId and 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 of GET /api/merchant/pay-in/{payInId}/status, payInId included.
  • 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 400 VALIDATION_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 every GET.
  • Migration. None: two new endpoints, nothing else changes. X-Api-Version is now 2026-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-in with that clientReference and a new merchantOrderId is refused with 400 CLIENT_BLOCKED and 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 exact clientReference. A repeat of an existing merchantOrderId still returns that pay-in, and pay-outs are not affected. See Blocked clients.
  • Hosted and embedded checkout. A refused create has no checkout object, so there is no checkout.url or checkout.embedUrl and 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, handle CLIENT_BLOCKED as final for that client: do not retry it and do not resend the payer under another clientReference. Nothing else changes: no field is added, removed or renamed. X-Api-Version is now 2026-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-out requests arrived within milliseconds and the pay-out balance covered only some of them, the others could be refused with 400 VALIDATION_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 as AwaitingFunds once 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 409 CONCURRENT_UPDATE instead of 400 VALIDATION_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, treat CONCURRENT_UPDATE as retryable. No field is added, removed or renamed, and X-Api-Version stays 2026-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 by GET /api/merchant/transfers and GET /api/merchant/transfers/{transferId} with source: "autoSettle" and clientReference: null. Before this version such a move would have been reported as manual.
  • Migration. If you map source to a fixed set of values, add autoSettle or accept unknown values. Nothing else changes. X-Api-Version is now 2026-10-16.

v2.16 — 2026-10-12

  • New: transfer from the pay-in balance to the pay-out balance. POST /api/merchant/transfers with amount and clientReference moves 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 409 IDEMPOTENCY_KEY_MISMATCH. An amount above availableBalance gets INSUFFICIENT_BALANCE, also when a concurrent withdrawal or transfer used the balance first.
  • GET /api/merchant/transfers/{transferId} and GET /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-Version is now 2026-10-12.
  • Fixed. POST /api/merchant/disputes/{id}/messages now checks that the dispute is yours before it stores any file. A dispute that belongs to another merchant or does not exist gets 404 NOT_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 over timestamp + "." 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}/utr answers another merchant's pay-in with 404 NOT_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) and GET /pay-in/{payInId}/status return three new always-present fields: exchangeRate, your exchange rate for the order, fixed when it was created, in units of currency per 1 USDT; commissionRate, your commission on the order in percent (8.5 means 8.5%); and amountUsdt, the USDT credited to your balance when the order completes.
  • How they fit together. requestedAmount / exchangeRate is 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 as amountUsdt in the pay_in.completed webhook. The rate and the commission do not change after creation. If support closes the order on a lower received amount (v2.12), amountUsdt on the status is recalculated on that amount at the same rate. 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.
  • Migration. Nothing is removed, renamed or reordered: the three fields are appended after the existing ones (after checkout when it is present). Webhooks, pay-outs and the other endpoints are unchanged. X-Api-Version is now 2026-10-06.

v2.14 — 2026-10-01

  • PhonePe and Paytm deep links on request. POST /api/merchant/pay-in accepts an optional boolean includeUpiAppLinks. When it is true and the order has upiIntentLink (a upi or upi_intent order served by a receiving account in the Atlas network), the response adds upiAppLinks { phonepe, paytm } right before checkout: 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 a upi://pay intent to a P2P VPA sometimes is. Keep upiIntentLink and 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 upi order now gets the same app choice as upi_intent: PhonePe, Paytm and Other UPI, which unfolds the QR and the UPI ID. The separate UPI button (the generic upi://pay intent) is gone from the page. Nothing changes in the API: upiIntentLink is 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 of GET /pay-in/{payInId}/status or the webhooks. X-Api-Version is now 2026-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 is Processing (the status that was reserved until now) and txHash stays null; once it is confirmed on chain the withdrawal is Completed with its txHash, as before.
  • Processing → Pending is possible. When an automatic transfer cannot be made, the withdrawal goes back to Pending and is paid by the finance team like any other. A withdrawal you request yourself (POST /api/merchant/withdrawals or the merchant dashboard) is unchanged and never sent automatically.
  • autoSettleEnabled is 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 Processing as “in flight” (it already blocks a new request with WITHDRAWAL_IN_FLIGHT) and keep polling until Completed or Failed. X-Api-Version is now 2026-09-28.

v2.12 — 2026-09-24

  • New always-present field originalAmount on GET /pay-in/{payInId}/status and on the pay_in.completed webhook, right after amountAdjustment: what you originally asked for. On every ordinary order it equals requestedAmount.
  • 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 Completed on what was received. The order is then reported with amount and requestedAmount equal to the received amount, the one you are credited for (amountUsdt is priced on it at the order's original rate), with amountAdjustment 0, isManualConfirmation true, and originalAmount keeping 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 requestedAmount as before; if originalAmount differs 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 the pay_in.expired / pay_in.failed webhooks are unchanged. X-Api-Version is now 2026-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, 998 for a request of 1000) or ±1.5 in steps of 0.5 (1000.5, 999.5, 1001, …). amountAdjustment stays within ± that range, can be negative and may be fractional, and amount may then carry paise. When it is not enabled the order gets the exact amount or NO_CARD_AVAILABLE, as before the adjustment existed. You are still credited for requestedAmount. No field is added, removed or renamed and X-Api-Version is 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, 1002 for a request of 1000), nearest first, and the side is chosen so that over time the adjustments cancel out. amount no longer carries paise on a whole-rupee request. amountAdjustment is therefore -2, -1, 0, 1 or 2: accept a negative value. You are still credited for requestedAmount, whichever way the amount moved. No field is added, removed or renamed and X-Api-Version stays 2026-09-21.

v2.11 — 2026-09-21

  • Two always-present fields join amount on the pay-in create response, on GET /pay-in/{payInId}/status and on the pay_in.completed / pay_in.expired / pay_in.failed webhooks: requestedAmount, what you sent and what you are credited for, and amountAdjustment, which is amount minus requestedAmount. Nothing is removed or renamed and no existing value changes meaning; reconcile your order on requestedAmount and merchantOrderId. X-Api-Version is now 2026-09-21.
  • On a UPI order (paymentMethod upi or upi_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). An imps order is never moved: its payer types the amount into a bank app by hand. amount is what the payer has to transfer: show it exactly. You are credited for requestedAmount.
  • In this market requestedAmount always equals amount and amountAdjustment is always 0.

v2.10 — 2026-09-19

  • The UPI link is now payee + amount + currency only. upiIntentLink in the pay-in create response, and the QR / “Pay in UPI app” link on the hosted and embedded checkout page, are now upi://pay?pa=<vpa>&pn=<payee>&am=<amount, 2dp>&cu=INR. The tn and tr parameters (both carried our displayId) are removed: tr on 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 upiIntentLink as opaque; if you parsed tn / tr out of it to recover the order id, read displayId from the same response instead. Confirmation is unchanged: UTR and exact amount. X-Api-Version is now 2026-09-19.

v2.9 — 2026-09-14

  • Pay-in create response is now per-geo. POST /api/merchant/pay-in returns 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 as null or "", so the empty-string and null placeholders that used to travel with every order are gone. qrImageBase64 and qrPayload are 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_intent order returns upiVpa + upiIntentLink; an imps order returns accountNumber + ifscCode.
  • Which requisite keys you get. A South Korea kor_bank order returns accountNumber; a kakao_pay order returns qrPayload (+ 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}/status and the webhooks are unchanged. X-Api-Version is now 2026-09-14.

v2.8 — 2026-09-11

  • Hosted and embedded checkout now serve South Korea. checkout is accepted for geo: SouthKorea on both Korean pay-in rails, paymentMethod: "kor_bank" and "kakao_pay". Same page, same checkout { url, expiresAt, locale, embedUrl } object, same redirect contract, same loader and events. The CHECKOUT_NOT_SUPPORTED message now lists South Korea (paymentMethod kor_bank or kakao_pay) among the scopes a payment page is available for.
  • What the payer sees. kor_bank renders the receiving account's number, bank and account holder for a manual bank transfer, with no QR and no deep link. kakao_pay renders the receiving account's Kakao Pay QR, plus a tap-to-open button when the payload is a kakaopay.com link (a raw EMV payload is shown as a QR only). Page languages are ko (default) and en; checkout.locale resolves 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}/claim answers 400 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,567 with 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 checkout sees byte-identical behaviour. X-Api-Version is now 2026-09-11. Deploy prerequisite: the merchant must have SouthKorea in its allowed geos and a merchant_method_spreads pay-in row for each Korean method it uses; a missing pay-in spread is METHOD_NOT_CONTRACTED and 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: checkout gains a fourth member embedUrl (https://api.atlas-online.io/embed/{token}), appended after locale; the existing three are never re-ordered. It is present whenever the checkout object itself is present, and the whole checkout key is still omitted when you did not ask for a page, so a v2.6 integration sees a byte-identical response. url and embedUrl are alternatives, not a sequence: redirect the payer to url, or embed embedUrl.
  • How to embed. Load https://api.atlas-online.io/embed/v1/atlas.js and call Atlas.mount('#atlas-pay', { url: checkout.embedUrl }). The loader creates one cross-origin <iframe>, keeps its height in sync with the content and raises ready, resize, claim, success, expired, failed, cancel, intent, download, focus and error events. 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.completed webhook or GET /pay-in/{payInId}/status. cancel means 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 (and error {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 with mount({ appearance }). Schema-1 themes keep working unchanged.
  • No new endpoints, error codes or webhook events on the HMAC surface. X-Api-Version is now 2026-09-10.

v2.6 — 2026-09-09

  • New: India UPI pay-outs. POST /api/merchant/pay-out accepts paymentMethod: "upi" together with the new optional recipientUpiVpa (UPI ID, handle@psp, stored lower-cased) as the whole recipient object. imps stays the default when paymentMethod is 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 same pay_out.completed webhook.
  • The two India pay-out rails are mutually exclusive. recipientUpiVpa on an imps pay-out ("recipientUpiVpa is only valid for paymentMethod upi") and recipientAccountNumber/recipientBank/recipientIfsc on a upi pay-out ("…are not valid for India UPI pay-outs; send recipientUpiVpa only") are both VALIDATION_ERROR; so is recipientUpiVpa on a pay-out in any other geo ("recipientUpiVpa is only valid for India"). A paymentMethod other than imps/upi now 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.
  • recipientUpiVpa is echoed back on the pay-out create response, on GET /pay-out/{payOutId}/status and in the pay_out.completed webhook, immediately after recipientIfsc (which stays in the payload as null on 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.failed and pay_out.expired are unchanged.
  • New India pay-in method upi_intent ("UPI Intent", rail upi). GET /payment-methods?geo=India now returns { "code": "upi_intent", "name": "UPI Intent", "type": "upi" } alongside upi and imps, and the India paymentMethod on pay-in create is one of upi | upi_intent | imps. Behaviour, UTR confirmation, spreads and webhooks are identical to upi. The two are one rail, so a upi_intent order 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 link upi://pay?pa=…&pn=…&am=…&cu=INR&tn=<displayId>&tr=<displayId>, right after ifscCode. Present for every India pay-in on the UPI rail (upi and upi_intent), idempotent replays included; the key is omitted for imps, for orders in every other geo and for cascade-routed orders. It is not in the webhooks and not in GET /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 for upi.
  • Hosted checkout scope widened from paymentMethod == "upi" to all three India methods: upi, upi_intent and imps. One page adapts to the method: upi shows the QR plus the UPI ID, upi_intent adds a upi://pay app button on phones, imps shows account number, IFSC, bank and account holder and no QR at all. The CHECKOUT_NOT_SUPPORTED message now names all three India methods.
  • No new webhook events and no new error codes. Additive on every other endpoint and payload. X-Api-Version is now 2026-09-09.

v2.5 — 2026-09-07

  • New: Hosted checkout. Optional checkout object on POST /api/merchant/pay-in requests an Atlas-hosted payment page instead of embedding recipient details directly; see Hosted checkout.
  • MVP scope gate. checkout is accepted only for geo: India + paymentMethod: "upi" routed to the Atlas network. Outside that scope the request is rejected with the new error code CHECKOUT_NOT_SUPPORTED and no pay-in is created.
  • When checkout is present, expiresIn must be within 300–3600 seconds.
  • Response gains a trailing checkout object (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 same merchantOrderId returns the same checkout.url.
  • Redirect contract. The payer is returned to returnUrl with 302 GET returnUrl?payInId=…&merchantOrderId=… for every outcome (success / not confirmed / expired / "start again"); only an explicit payer cancel goes to cancelUrl (falls back to returnUrl). No status and no signature in the URL: the redirect is not a confirmation. Credit orders only from the signed pay_in.completed webhook or GET /pay-in/{payInId}/status.
  • Expiry can move. Submitting a UTR on the hosted page may extend expiresAt once by up to 30 minutes, so pay_in.expired can 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-Version is now 2026-09-07.

v2.4 — 2026-09-02

  • New: Finance API, balance and withdrawals. GET /api/merchant/balance returns your USDT position (pay-in balance availableBalance, pay-out balance payOutBalance, reservedBalance, totalBalance), the configured walletAddress and the withdrawal currently in flight.
  • POST /api/merchant/withdrawals requests a withdrawal of the pay-in balance to your wallet address, with an optional partial amount and 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} and GET /api/merchant/withdrawals (paged, status filter) to track Pending → Processing → Completed | Failed. No webhook for withdrawals yet, so poll.
  • New error codes NO_WALLET_ADDRESS and WITHDRAWAL_IN_FLIGHT. INSUFFICIENT_BALANCE is reused for withdrawals above the pay-in balance.
  • Purely additive: existing endpoints and payloads are unchanged. X-Api-Version is now 2026-09-02.

v2.3 — 2026-08-07

  • New geo: South Korea (geo = 4 / SouthKorea, currency KRW). Pay-in rails: kor_bank (bank + account number) and kakao_pay (QR payload). Pay-outs: kor_bank only. paymentMethod: "kakao_pay" on a pay-out returns VALIDATION_ERROR; omitted defaults to kor_bank.
  • paymentMethod is 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_bank order returns accountNumber + bankName + ownerName; a kakao_pay order returns qrPayload (+ qrImageBase64 when an image is attached). No new response fields, so existing parsers are unaffected.
  • requiresManualConfirmation is always true for 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=SouthKorea returns kor_bank and kakao_pay; GET /banks?geo=SouthKorea returns the KR bank catalog (KB Kookmin, Shinhan, Woori, …).

v2.2 — 2026-07-31

  • New geo: India (geo = 3 / India, currency INR). Pay-in rails: upi (VPA + QR) and imps (bank account + IFSC). Pay-outs: IMPS only. paymentMethod: "upi" on a pay-out returns VALIDATION_ERROR; omitted defaults to imps.
  • paymentMethod is 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 + new recipientIfsc (11-char IFSC, validated as AAAA0XXXXXX). 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/qrImageBase64 carry 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. requiresManualConfirmation is always true for India (Atlas confirms the payment against the UTR).
  • pay_out.completed webhook + pay-out status: new nullable recipientIfsc field. Additive; carries the IFSC on India pay-outs, null elsewhere.
  • GET /payment-methods: type is now per-method. India returns upi and imps under one geo; the values returned for other geos are unchanged. GET /banks?geo=India returns [], because the IFSC fully addresses the bank.
  • Doc correction: the banks catalog kind field has always been Bank. Earlier versions of this page incorrectly showed Wallet sample entries.

v2.1.1 — 2026-06-15

  • paymentMethod is optional again on POST /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 method code only when you need a specific one. A missing value is no longer an error.

v2.1 — 2026-06-11 Breaking

  • paymentMethod is now required on POST /pay-in and is the method code. Send the payment method code for the geo (for example 0102 = Banco de Venezuela, 0108 = Provincial), the same vocabulary as the pay-out recipientBank. The previously-optional rail name (cvuven_bank) is no longer the value. A missing value returns 400 VALIDATION_ERROR; an unknown or disabled code returns METHOD_NOT_CONTRACTED.
  • GET /api/merchant/payment-methods now returns code. Each item is { code, name, type }; pass the code as paymentMethod on pay-in create. type remains the provider rail, for reference only.

v2.0.1 — 2026-06-10

  • Consistent validation error shape. A missing required field on POST /pay-in and POST /pay-out now always returns the same envelope: {"error":"VALIDATION_ERROR","message":"<field> is required"}, using the lowercase JSON field name. Previously a missing clientReference on 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 every POST /pay-in and POST /pay-out request. Missing or invalid values return 400 VALIDATION_ERROR.
  • disputeAmount required on dispute create. Add disputeAmount (the contested amount in transaction currency; may be partial, must be ≤ transaction amount) to every POST /disputes request.
  • Venezuela pay-out: new required field recipientAccountNumber. Add recipientAccountNumber (bank account number / número de cuenta) alongside the existing recipientPhone, recipientCedula, and recipientBank for 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: recipientPhone and recipientCedula, 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. The type value maps directly to the new optional paymentMethod field on pay-in create.
  • AwaitingFunds pay-out status. A pay-out submitted before your balance is funded is now accepted (201) and held in AwaitingFunds status for up to ~12 h, then settled or auto-expired (pay_out.expired). Treat 201 as "accepted, not yet settled" and wait for the webhook.
  • Pay-in status echoes clientReference and qrPayload. GET pay-in status now returns clientReference (echo) and qrPayload.
  • Pay-out status echoes clientReference. GET pay-out status now returns clientReference and geo, plus all four Venezuela recipient fields.
  • Webhook updates. pay_in.completed adds geo and utr; pay_out.completed adds geo and the Venezuela recipient fields; new pay_in.failed event with reason.
  • New error codes. METHOD_NOT_CONTRACTED (requested paymentMethod not enabled for this merchant) and SPREAD_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_MINIMUM and AMOUNT_ABOVE_MAXIMUM. Each response carries the configured limit in the message field.
  • New error code INSUFFICIENT_BALANCE on 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_MISMATCH for accounts opted into strict idempotency. A repeated merchantOrderId sent 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=Venezuela returns a structured catalog. Each entry has a stable code (for example "0134" for Banesco), name, and kind (Bank or Wallet). HMAC-authenticated like the rest of /api/merchant/*.
  • pay_in.completed webhook payload includes a new boolean isManualConfirmation. It is true when the pay-in was confirmed manually after its TTL elapsed.
  • The utr field is validated for length (max 128 characters) at the API layer.
  • Empty or null currency is rejected with VALIDATION_ERROR.
  • Invalid geo values now return a dedicated enum error, separate from the "geo not enabled for this account" rule.