Milanj.Money Payout API
Disburse funds to bank beneficiaries server-to-server. Exchange your credentials for a bearer token, then send AES-256-GCM encrypted payout and status requests.
https://api.settlor.money/api/v1AES-256-GCMBearer tokenINR onlyIMPS · NEFT · RTGSOverview
The Milanj.Money Payout API is a server-to-server integration for disbursing funds to bank beneficiaries. Every request uses a short-lived bearer token plus AES-256-GCM authenticated payload encryption. The flow is three steps: exchange your credentials for a token, then send encrypted payout and status requests.
| Property | Description |
|---|---|
| Base URL | https://api.settlor.money/api/v1 |
| Auth model | API-Key + Secret-Key → bearer token (1 hour TTL) |
| Encryption | AES-256-GCM (AEAD); fresh 12-byte nonce per call; base64(nonce ‖ ciphertext ‖ tag) |
| Currency | INR only |
| Idempotency | Idempotency-Key header on payout init (strongly recommended) |
| Rate limit | Per source IP, default 100 requests / minute |
| Errors | Plain JSON, never encrypted |
The flow
- 1. Token —
POST /merchant/auth/tokenwith yourX-API-Key+X-Secret-Keyheaders returns a bearer token. - 2. Initiate —
POST /merchant/payoutswith the bearer token and an encrypted body returns an encrypted payout result. - 3. Status —
POST /merchant/payouts/statuspolls the payout until it reaches a terminal state. - Balance —
POST /merchant/payouts/balancechecks wallet balances any time (one wallet, or all of them).
Authentication
/api/v1/merchant/auth/tokenThe headers X-API-Key and X-Secret-Key are the authentication — no bearer token yet, no encryption. This is the bootstrap. No request body is required.
Response (200)
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600
}
}token— an HS256 JWT (iss="merchant:api",mid= merchant id). Send it asAuthorization: Bearer <token>on every subsequent call.expires_in— seconds until expiry (default 3600). Cache the token; refresh on a 401 or proactively before it lapses.
The credential row is re-validated on every request, so rotating or revoking keys invalidates outstanding tokens on their next call.
Encryption spec (AES-256-GCM)
Every payout and status body is an encrypted envelope. GCM is an authenticated cipher — the 16-byte tag is verified before decryption, and your api_key is bound in as the GCM AAD so a captured envelope cannot be replayed under a different credential.
| Parameter | Value |
|---|---|
| Algorithm | AES-256-GCM (AEAD) |
| Key | 32 bytes — hex.decode(salt_key) |
| Nonce | 12 random bytes per call (CSPRNG, never reused with the same key) |
| Tag | 16 bytes, appended after the ciphertext |
| AAD | your api_key as raw UTF-8 bytes (NOT hex-decoded) |
| Wire format | base64(nonce ‖ ciphertext ‖ tag) |
| Charset | Standard base64 (+, /, =) — NOT URL-safe |
| Envelope | {"data": "<base64>"} |
- Nonce reuse is catastrophic under GCM — always draw a fresh 12-byte nonce from a CSPRNG.
- Hex-decode the salt_key to 32 raw bytes before using it as the key.
- Use standard base64, not URL-safe. Keep
Content-Type: application/json— the cipher lives inside the JSON.
Reference implementation
const crypto = require('crypto')
function encrypt(plaintext, saltKeyHex, apiKey) {
const key = Buffer.from(saltKeyHex, 'hex') // 32 bytes
const nonce = crypto.randomBytes(12) // never reuse with the same key
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 })
cipher.setAAD(Buffer.from(apiKey, 'utf8')) // raw api_key bytes
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
return Buffer.concat([nonce, ct, cipher.getAuthTag()]).toString('base64')
}Want to see this run? The Playground encrypts a payout body live in your browser and shows the exact wire envelope.
Initiate a payout
/api/v1/merchant/payoutsSend the bearer token and an encrypted body. Include an Idempotency-Key header (UUID v4 recommended) so retries replay the cached result instead of double-disbursing.
Request fields (plaintext, before encryption)
| Field | Type | Req | Notes |
|---|---|---|---|
| amount | number | yes | INR major units, max 2 decimals, > 0 |
| payment_mode | string | yes | One of NEFT, IMPS, RTGS |
| beneficiary_name | string | yes | 1–120 chars |
| beneficiary_account | string | yes | 6–34 chars |
| beneficiary_ifsc | string | yes | exactly 11 chars |
| bank_name | string | yes | 1–120 chars |
| beneficiary_address | string | no | ≤ 255 chars |
| reference_id | string | no | ≤ 64 chars, unique per merchant (forever) |
| remarks | string | no | ≤ 255 chars |
| wallet_id | string (UUID) | no | target a specific PAYOUT wallet; omit for smart routing |
{
"amount": 100.50,
"payment_mode": "IMPS",
"beneficiary_name": "John Doe",
"beneficiary_account": "1234567890",
"beneficiary_ifsc": "HDFC0001234",
"bank_name": "HDFC Bank",
"reference_id": "INV-2026-04-001",
"remarks": "Invoice settlement"
}Response (201, decrypted)
{
"transaction_id": "e64da5f7-70de-446c-bfda-af267fca1f00",
"transaction_code": "lp-po-1S04X1X180400",
"status": "INITIATED",
"amount": 100.50,
"fee": 0.0,
"net_amount": 100.50,
"currency": "INR",
"payment_mode": "IMPS",
"beneficiary": {
"name": "John Doe",
"account": "1234567890",
"ifsc": "HDFC0001234",
"bank_name": "HDFC Bank"
},
"reference_id": "INV-2026-04-001",
"created_at": "2026-05-12T09:39:35.378048Z"
}Status lifecycle
On INITIATED the funds are placed on HOLD. SUCCESS settles the debit; FAILED / REVERSED release the HOLD. A late REVERSED after SUCCESS is rare but possible — re-check status at T+1 for anything you credit downstream.
Full example
# Requires python3 + 'pip install cryptography' for the AEAD helpers.
API_KEY="<your api_key>"
SALT_KEY="<your salt_key>" # 64-char hex
BASE_URL="https://api.settlor.money/api/v1"
# 1) Token
TOKEN=$(curl -s -X POST "$BASE_URL/merchant/auth/token" \
-H "X-API-Key: $API_KEY" -H "X-Secret-Key: $SALT_KEY" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['token'])")
# Helper: encrypt stdin -> base64 envelope
enc() {
API_KEY="$API_KEY" SALT_KEY="$SALT_KEY" python3 -c '
import os, sys, base64, binascii
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = binascii.unhexlify(os.environ["SALT_KEY"])
aad = os.environ["API_KEY"].encode()
nonce = os.urandom(12)
ct = AESGCM(key).encrypt(nonce, sys.stdin.buffer.read(), aad)
sys.stdout.write(base64.b64encode(nonce + ct).decode())'
}
# 2) Initiate the payout
PAYLOAD='{"amount":100.5,"payment_mode":"IMPS","beneficiary_name":"John Doe","beneficiary_account":"1234567890","beneficiary_ifsc":"HDFC0001234","bank_name":"HDFC Bank","beneficiary_address":"Mumbai, MH","reference_id":"INV-2026-04-001","remarks":"Invoice settlement"}'
ENVELOPE=$(printf '%s' "$PAYLOAD" | enc)
curl -s -X POST "$BASE_URL/merchant/payouts" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "{\"data\":\"$ENVELOPE\"}" | python3 -m json.toolCheck payout status
/api/v1/merchant/payouts/statusSame auth and encryption as initiate (no Idempotency-Key— it's a read). The encrypted body wraps one of {"transaction_id": "..."} or {"reference_id": "..."}. If both are supplied, transaction_id wins.
Response (200, decrypted)
{
"transaction_id": "e64da5f7-70de-446c-bfda-af267fca1f00",
"transaction_code": "lp-po-1S04X1X180400",
"status": "SUCCESS",
"amount": 100.50,
"net_amount": 100.50,
"currency": "INR",
"payment_mode": "IMPS",
"reference_id": "INV-2026-04-001",
"external_ref": "UTR-202604121234567",
"created_at": "2026-05-12T09:39:35Z",
"updated_at": "2026-05-12T09:39:42Z"
}external_refis the PSP's UTR / NPCI reference — always present, but an empty string until the PSP settles. Poll no faster than every 5 seconds: 5s, 10s, 30s, 60s, then once per minute until terminal.
Check balance
/api/v1/merchant/payouts/balanceSame auth and encryption as the other calls (no Idempotency-Key— it's a read). The encrypted body wraps {"wallet_code": "..."} to fetch a single wallet, or an empty {} to return every active PAYOUT wallet assigned to your account. wallet_code is optional (max 64 chars).
Response (200, decrypted)
{
"wallets": [
{
"wallet_id": "8f1c7d2e-3b4a-4c5d-9e6f-0a1b2c3d4e5f",
"wallet_code": "WALLET-VA-001",
"provider_name": "Acme PSP",
"currency": "INR",
"balance": 15000.50,
"hold_balance": 500.00,
"available_balance": 14500.50,
"status": "ACTIVE",
"priority": 1
}
]
}The response is always a wallets array (a single element when a wallet_code is supplied). available_balance is what you can spend now, hold_balance is reserved for in-flight payouts, and balance is the total (available + hold). All amounts are in rupees and currency is INR.
Webhooks
When a payout reaches a terminal state, Milanj.Moneywill POST a signed JSON event to your configured endpoint so you don't have to poll. Events carry status fields only — never beneficiary PII.
Event types
| Event | Fires when |
|---|---|
payout.succeeded | Payout settled — funds left the wallet |
payout.failed | PSP rejected the payout — HOLD released |
payout.reversed | A settled payout was reversed (rare, ops-initiated) |
Delivery headers
| Header | Notes |
|---|---|
X-Settlor-Event | The event type, e.g. payout.succeeded |
X-Settlor-Delivery | Unique delivery id — dedupe on this (retries reuse it) |
X-Settlor-Signature | HMAC-SHA256 (hex) of the raw body, keyed with your salt_key |
Sample event
POST /your/webhook/endpoint HTTP/1.1
Content-Type: application/json
X-Settlor-Event: payout.succeeded
X-Settlor-Delivery: whd_3fa85f64-5717-4562-b3fc-2c963f66afa6
X-Settlor-Signature: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08{
"event": "payout.succeeded",
"delivery_id": "whd_3fa85f64-5717-4562-b3fc-2c963f66afa6",
"created_at": "2026-05-12T09:39:42Z",
"data": {
"transaction_id": "e64da5f7-70de-446c-bfda-af267fca1f00",
"transaction_code": "lp-po-1S04X1X180400",
"status": "SUCCESS",
"amount": 100.50,
"net_amount": 100.50,
"currency": "INR",
"payment_mode": "IMPS",
"reference_id": "INV-2026-04-001",
"external_ref": "UTR-202604121234567"
}
}Verify the signature
Compute HMAC-SHA256 over the raw request body (keyed with your salt_key) and compare it to X-Settlor-Signature in constant time. Reject with 401 on a mismatch, and never re-serialize the parsed JSON before hashing.
const crypto = require('crypto')
// Verify the webhook signature. Use the RAW request body — do NOT re-serialize the
// parsed JSON, or the signature will not match.
function verifyWebhook(rawBody, signatureHeader, saltKeyHex) {
const expected = crypto
.createHmac('sha256', Buffer.from(saltKeyHex, 'hex'))
.update(rawBody)
.digest('hex')
const a = Buffer.from(signatureHeader || '', 'hex')
const b = Buffer.from(expected, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// Express: capture the raw body for signature verification
app.post('/webhooks/settlor', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(req.body, req.get('X-Settlor-Signature'), process.env.SETTLOR_SALT_KEY)) {
return res.status(401).end()
}
const event = JSON.parse(req.body.toString('utf8'))
// ... handle event.event / event.data, idempotent on event.delivery_id
res.status(200).end()
})Delivery semantics
- Respond with a 2xx quickly (within ~5s); do heavy work asynchronously.
- Non-2xx or timeout is retried with exponential backoff — make your handler idempotent by keying on
delivery_id. - Treat status as advisory until verified — the status endpoint is always the source of truth.
Playground
Build a payout request below and watch it become the encrypted wire envelope — all computed in your browser with the Web Crypto API. Nothing is sent anywhere; this is a learning tool for the encryption format and a generator for ready-to-run code.
Encryption preview
{
"amount": 100.5,
"payment_mode": "IMPS",
"beneficiary_name": "John Doe",
"beneficiary_account": "1234567890",
"beneficiary_ifsc": "HDFC0001234",
"bank_name": "HDFC Bank",
"beneficiary_address": "Mumbai, MH",
"reference_id": "INV-2026-04-001",
"remarks": "Invoice settlement"
}Enter an api_key and salt_key above to compute the envelope.
Paste an envelope (your server's output, or the one above) and decrypt it with the current credentials.
Generated code
# Requires python3 + 'pip install cryptography' for the AEAD helpers.
API_KEY="<your api_key>"
SALT_KEY="<your salt_key>" # 64-char hex
BASE_URL="https://api.settlor.money/api/v1"
# 1) Token
TOKEN=$(curl -s -X POST "$BASE_URL/merchant/auth/token" \
-H "X-API-Key: $API_KEY" -H "X-Secret-Key: $SALT_KEY" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['token'])")
# Helper: encrypt stdin -> base64 envelope
enc() {
API_KEY="$API_KEY" SALT_KEY="$SALT_KEY" python3 -c '
import os, sys, base64, binascii
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = binascii.unhexlify(os.environ["SALT_KEY"])
aad = os.environ["API_KEY"].encode()
nonce = os.urandom(12)
ct = AESGCM(key).encrypt(nonce, sys.stdin.buffer.read(), aad)
sys.stdout.write(base64.b64encode(nonce + ct).decode())'
}
# 2) Initiate the payout
PAYLOAD='{"amount":100.5,"payment_mode":"IMPS","beneficiary_name":"John Doe","beneficiary_account":"1234567890","beneficiary_ifsc":"HDFC0001234","bank_name":"HDFC Bank","beneficiary_address":"Mumbai, MH","reference_id":"INV-2026-04-001","remarks":"Invoice settlement"}'
ENVELOPE=$(printf '%s' "$PAYLOAD" | enc)
curl -s -X POST "$BASE_URL/merchant/payouts" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "{\"data\":\"$ENVELOPE\"}" | python3 -m json.toolError reference
All errors return plain JSON (never encrypted) in the standard envelope: { "success": false, "error": { "code": "...", "message": "..." } }
| HTTP | Code | Meaning |
|---|---|---|
| 400 | MERCHANT_PAYLOAD_ENCODING_INVALID | Body not JSON or missing the data field |
| 400 | MERCHANT_PAYLOAD_DECRYPTION_FAILED | Wrong key, tampered ciphertext, or rotated salt |
| 400 | VALIDATION_ERROR | A decrypted field failed validation |
| 401 | MERCHANT_AUTH_MISSING_HEADERS | X-API-Key or X-Secret-Key missing |
| 401 | MERCHANT_AUTH_INVALID_CREDENTIALS | Wrong api_key or secret-key |
| 401 | MERCHANT_AUTH_CREDENTIALS_REVOKED | Credential rotated / deactivated |
| 401 | MERCHANT_AUTH_CREDENTIALS_EXPIRED | 90-day expiry passed |
| 401 | MERCHANT_AUTH_TOKEN_INVALID | Bearer token missing / malformed / expired |
| 403 | MERCHANT_API_IP_NOT_WHITELISTED | Source IP not in your allowlist |
| 404 | PAYOUT_WALLET_NOT_FOUND | No PAYOUT wallet — account not approved yet |
| 404 | PAYOUT_NOT_FOUND | No matching payout for this merchant |
| 409 | PAYOUT_WALLET_SUSPENDED | Wallet suspended or closed |
| 409 | PAYOUT_INSUFFICIENT_BALANCE | Selected wallet balance < net amount |
| 409 | INSUFFICIENT_WALLET_BALANCE | No eligible wallet (smart routing) |
| 409 | PAYOUT_WALLET_NOT_ELIGIBLE | Explicit wallet_id not eligible |
| 409 | PAYOUT_DUPLICATE_REFERENCE | reference_id already used |
| 409 | PAYOUT_IDEMPOTENCY_CONFLICT | Same Idempotency-Key, different payload |
| 409 | PAYOUT_CONCURRENT_UPDATE | Same key raced — retry after backoff |
| 429 | RATE_LIMIT_EXCEEDED | Slow down |
| 500 | INTERNAL_ERROR | Server fault, retry later |
Rate limits & common pitfalls
Rate limits
A sliding-window limiter, keyed per source IP, allows 100 requests / minute across all /api/v1/ endpoints. X-RateLimit-* headers are returned on every response so you can pace your client.
Common pitfalls
- Don't reuse nonces — fresh CSPRNG nonce per encryption call.
- Standard base64, not URL-safe; hex-decode the salt_key to 32 bytes first.
- Pass
api_keyas raw UTF-8 for the AAD — it looks hex but is an opaque identifier. - Always retry with the same
Idempotency-Key— without it, retries can double-disburse. reference_iduniqueness is per-merchant and permanent, even for failed payouts.- Token TTL is 1 hour — cache it; rotating keys invalidates in-flight tokens.
- The IP allowlist applies to the token endpoint too.