Host to host payment API for a payment screen you design
Your server signs each request with HMAC-SHA256, creates the pay-in and gets back what the buyer needs to pay: a QR, a UPI ID, app links or bank details. You draw the screen. ATLAS confirms the payment and sends you a signed webhook.
How an H2H pay-in works
In an H2H integration the buyer never touches an ATLAS page. Your backend talks to ours, and your front end shows the result.
-
Sign and create
the create request, signed with the HMAC headers
-
Show the details
render the payment fields from the 201 response on your screen
-
Pass on the reference
in India, relay the buyer's UTR to the UTR endpoint
-
Credit on the webhook
wait for the completion webhook, or read the status endpoint
The same flow covers all four markets in the public reference: Argentina, Venezuela, India and South Korea. It is also the only path in Argentina and Venezuela, where the hosted page is not offered.
The API docs list every field of the create pay-in request, market by market.
{ "amount": 3049, "currency": "INR", "geo": 3, "paymentMethod": "upi"} { "utr": "0086543219" } { "event": "pay_in.completed", "status": "Completed", "utr": "0086543219"} HMAC signed requests, byte for byte
Each request carries X-Api-Key, X-Timestamp and X-Signature. The signature is the lowercase hex HMAC-SHA256 of timestamp + "." + raw_request_body, keyed with your apiSecret.
Sign the exact bytes you send. If your HTTP client re-serialises the JSON or reorders keys after you sign, the signature fails. GET requests and multipart uploads sign an empty body, so the canonical string is the timestamp and a dot. Query parameters are not part of the signature.
Keep your server clock on NTP. The window is 300 seconds either way: drift of 30 seconds is harmless, while drift past 300 seconds fails every call. Keep apiSecret out of logs, error reporters and version control. When you rotate it through your account manager, the old secret stays valid for one hour while you switch.
A failed check returns 401 with a code that names the problem: MISSING_API_KEY, MISSING_SIGNATURE, MISSING_TIMESTAMP, INVALID_TIMESTAMP, EXPIRED_REQUEST, INVALID_API_KEY or INVALID_SIGNATURE. Don't retry a 401. Fix the key or the signing code. The payments API overview has the full Node.js signing helper.
canonical = timestamp + "." + raw_request_body// GET / multipart: body is emptycanonical = "1776172416." X-Api-Key: mk_a838ef5b55d93add046ec9ea426420fef3dca5557d58ebd1X-Timestamp: 1776172416X-Signature: 9f2a7e4b5c8d1a3e0f6b2d9e7c4a5b8f1d2e3c4a5b6d7e8f9a0b1c2d3e4f5a6bContent-Type: application/json Response fields for your own payment screen
The 201 response carries only the payment details that apply to the order's market and rail. A key that does not apply is left out, never sent as null or an empty string, so read keys by name. Every response has payInId, displayId, ownerName, bankName, amount, currency and expiresAt. Show amount to the buyer exactly as returned.
| Method | Fields to show |
|---|---|
UPI (upi, upi_intent) | upiVpa, upiIntentLink, qrImageBase64, qrPayload |
IMPS (imps) | accountNumber, ifscCode |
Korean bank transfer (kor_bank) | accountNumber |
Kakao Pay (kakao_pay) | qrImageBase64, qrPayload |
| Pago Móvil (Venezuela) | recipientPhone, recipientCedula |
| CVU (Argentina) | cbu |
qrImageBase64 is a ready PNG data URI. qrPayload is the raw string (EMV or upi://pay) if you'd rather draw the QR yourself. The list of payment methods explains each rail, and GET /api/merchant/payment-methods?geo={geo} returns the codes your account can send.
For UPI and IMPS, the API docs map each response key to what you show the payer in payment screen options.
UPI: QR, intent link and app deep links
A UPI order gives you four ways to present the same UPI ID and amount, and you can show several at once. The QR works everywhere and is the main option on desktop. The UPI ID with the amount is the manual fallback, and the only one where the buyer can mistype. upiIntentLink opens the phone's UPI app chooser with payee, amount and currency filled in, mostly on Android.
Send "includeUpiAppLinks": true and the response adds upiAppLinks.phonepe and upiAppLinks.paytm. Each opens that app's own send-money screen with the payee and a locked amount. Use the links as opaque strings in a button href, unchanged.
Keep the QR and the UPI ID on screen next to the app buttons. A tap does nothing visible if the app is missing or an update moves its screen. None of these links work inside a nested iframe or most in-app WebViews. Our own pay page uses three choices, PhonePe, Paytm and Other UPI, and you can copy that layout.
qrImageBase64 upiVpa upiAppLinks.phonepe upiAppLinks.paytm UTR submission and confirmation by market
In India the UTR is required evidence. A pay-in there cannot be confirmed without one, either sent by you or recorded by ATLAS when it confirms the payment. Ask the buyer for it after they pay and send it to POST /api/merchant/pay-in/{payInId}/utr with { "utr": "0086543219" }. The response returns the current status in the same call.
In South Korea neither rail carries a reference, and a pay-in confirms without one. If your screen collects one anyway, send it, and ATLAS uses it as extra matching context. Venezuelan pay-ins are confirmed by hand, and Argentine pay-ins confirm automatically from bank notifications. requiresManualConfirmation in the response tells you which kind of order you have.
How UPI and IMPS pay-ins behave in practice is covered on the India market page.
{ "utr": "0086543219" } { "payInId": "1c75c312-93f3-4b34-903e-10fb38a56bec", "displayId": "PI00000007", "status": "WaitingPayment", "utr": "0086543219", "requiresManualConfirmation": true} Status polling or webhooks
Use the webhook as the source of truth and polling as the backup. pay_in.completed arrives signed in X-Webhook-Signature, carries merchantOrderId, geo, payerName and, in India, the utr, and gets up to 10 delivery attempts if your endpoint fails.
While you wait, GET /api/merchant/pay-in/{payInId}/status returns WaitingPayment, Completed, Expired or Failed, plus completedAt, utr and isManualConfirmation. The reference client in the docs polls this endpoint in a loop until the status leaves WaitingPayment.
upiIntentLink and upiAppLinks appear only in the create response. They are not in the status response or the webhooks, so keep them if your screen can be reloaded. Replaying the same merchantOrderId with the same flag returns the same links.
The API docs show how to verify a webhook signature, with a Node.js sample.
Idempotency, errors and disputes
merchantOrderId is your idempotency key: repeat it and you get the original pay-in back. clientReference identifies the buyer on your side, up to 64 characters from [A-Za-z0-9._:-], and is required on every pay-in. Errors come as { "error", "message" }. NO_CARD_AVAILABLE means no capacity at that moment and can be retried after a short delay. METHOD_NOT_CONTRACTED means the method is not enabled for your account.
When a buyer says they paid and the order was not credited, open a dispute through the API. POST /api/merchant/disputes takes transactionType, transactionId, reason, merchantOrderId and disputeAmount, which can be less than the full amount. The dispute starts in PendingEvidence with a 24-hour evidence deadline. You add messages and up to 10 files per request, 50 MB in total, through /api/merchant/disputes/{id}/messages, and dispute.opened and dispute.resolved arrive as webhooks.
Pay-outs over the same server-to-server API
The same keys and signing send money out. POST /api/merchant/pay-out takes the recipient fields of one rail. In India that is a recipientUpiVpa with "paymentMethod": "upi", or an account number, bank and IFSC for IMPS. Elsewhere it is a CBU in Argentina, phone, cédula, bank and account number in Venezuela, and an account number and bank in South Korea. Send nothing from another rail, or you get VALIDATION_ERROR. In South Korea, GET /api/merchant/banks?geo={geo} fills your bank picker.
The 201 response returns payOutId, displayId, amountFiat and expiresAt. The outcome arrives as pay_out.completed, pay_out.failed or pay_out.expired, and the completed event echoes the recipient fields. Every rail and its recipient fields are on the pay-outs page, and ATLAS for merchants has the commercial side.
FAQ
What is a host to host (H2H) payment API?
An integration where your server calls the payment provider directly and you show the payment details in your own interface. With ATLAS, POST /api/merchant/pay-in returns the QR, UPI ID, app links or bank details for your screen.
How do I sign ATLAS API requests?
Compute HMAC-SHA256 over the timestamp, a dot and the raw body with your apiSecret, and send it as lowercase hex in X-Signature with X-Api-Key and X-Timestamp. GET requests sign an empty body.
Which fields do I show for a UPI payment?
upiVpa and amount, the QR from qrImageBase64, and upiIntentLink as a tap target on Android. Add includeUpiAppLinks: true to get PhonePe and Paytm buttons.
Is the UTR required?
In India, yes: a pay-in cannot be confirmed without it, so send it to /pay-in/{payInId}/utr. In South Korea it is optional.
Should I poll the status or wait for webhooks?
Credit the order on the signed pay_in.completed webhook and poll GET /api/merchant/pay-in/{payInId}/status as a fallback.
Can I open a dispute through the API?
Yes. POST /api/merchant/disputes opens one against a completed pay-in or pay-out, and you attach evidence through the messages endpoint within 24 hours.
Which markets does the H2H API cover?
Argentina, Venezuela, India and South Korea in the public reference. It is the only path in Argentina and Venezuela, where the hosted page is not offered.