Milanj.Money
Merchant API · v1

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 · RTGS

Overview

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.

PropertyDescription
Base URLhttps://api.settlor.money/api/v1
Auth modelAPI-Key + Secret-Key → bearer token (1 hour TTL)
EncryptionAES-256-GCM (AEAD); fresh 12-byte nonce per call; base64(nonce ‖ ciphertext ‖ tag)
CurrencyINR only
IdempotencyIdempotency-Key header on payout init (strongly recommended)
Rate limitPer source IP, default 100 requests / minute
ErrorsPlain JSON, never encrypted

The flow

  • 1. TokenPOST /merchant/auth/token with your X-API-Key + X-Secret-Key headers returns a bearer token.
  • 2. InitiatePOST /merchant/payouts with the bearer token and an encrypted body returns an encrypted payout result.
  • 3. StatusPOST /merchant/payouts/status polls the payout until it reaches a terminal state.
  • BalancePOST /merchant/payouts/balance checks wallet balances any time (one wallet, or all of them).

Your servers must be IP-allowlisted (API_TRANSACTIONS) and your credentials are valid for 90 days. Configure both in the merchant dashboard under Settings.

Authentication

POST/api/v1/merchant/auth/token

The 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 as Authorization: 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.

ParameterValue
AlgorithmAES-256-GCM (AEAD)
Key32 bytes — hex.decode(salt_key)
Nonce12 random bytes per call (CSPRNG, never reused with the same key)
Tag16 bytes, appended after the ciphertext
AADyour api_key as raw UTF-8 bytes (NOT hex-decoded)
Wire formatbase64(nonce ‖ ciphertext ‖ tag)
CharsetStandard 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

POST/api/v1/merchant/payouts

Send 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)

FieldTypeReqNotes
amountnumberyesINR major units, max 2 decimals, > 0
payment_modestringyesOne of NEFT, IMPS, RTGS
beneficiary_namestringyes1–120 chars
beneficiary_accountstringyes6–34 chars
beneficiary_ifscstringyesexactly 11 chars
bank_namestringyes1–120 chars
beneficiary_addressstringno≤ 255 chars
reference_idstringno≤ 64 chars, unique per merchant (forever)
remarksstringno≤ 255 chars
wallet_idstring (UUID)notarget a specific PAYOUT wallet; omit for smart routing
plaintext payload
{
  "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

INITIATEDPROCESSINGSUCCESSFAILEDREVERSED

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

Check payout status

POST/api/v1/merchant/payouts/status

Same 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

POST/api/v1/merchant/payouts/balance

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

An unknown wallet_code — or an account with no active PAYOUT wallet yet — returns 404 PAYOUT_WALLET_NOT_FOUND.

Webhooks

Preview. Outbound webhooks are on the roadmap. Until they ship, reconcile by polling /merchant/payouts/status(re-check at T+1 for anything you credit downstream). The event shape and signing below are the planned contract — build against them now and they'll light up without code changes.

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

EventFires when
payout.succeededPayout settled — funds left the wallet
payout.failedPSP rejected the payout — HOLD released
payout.reversedA settled payout was reversed (rare, ops-initiated)

Delivery headers

HeaderNotes
X-Settlor-EventThe event type, e.g. payout.succeeded
X-Settlor-DeliveryUnique delivery id — dedupe on this (retries reuse it)
X-Settlor-SignatureHMAC-SHA256 (hex) of the raw body, keyed with your salt_key

Sample event

request
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
body
{
  "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.

Runs entirely in your browser — nothing is sent anywhere.

Use dev/test credentials only. Never paste a production salt_key into any browser.

Credentials

Public identifier; bound as the GCM AAD.

64-char hex; hex-decodes to the 32-byte AES key.

Payout request

optional, unique per merchant

optional

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

Error reference

All errors return plain JSON (never encrypted) in the standard envelope: { "success": false, "error": { "code": "...", "message": "..." } }

HTTPCodeMeaning
400MERCHANT_PAYLOAD_ENCODING_INVALIDBody not JSON or missing the data field
400MERCHANT_PAYLOAD_DECRYPTION_FAILEDWrong key, tampered ciphertext, or rotated salt
400VALIDATION_ERRORA decrypted field failed validation
401MERCHANT_AUTH_MISSING_HEADERSX-API-Key or X-Secret-Key missing
401MERCHANT_AUTH_INVALID_CREDENTIALSWrong api_key or secret-key
401MERCHANT_AUTH_CREDENTIALS_REVOKEDCredential rotated / deactivated
401MERCHANT_AUTH_CREDENTIALS_EXPIRED90-day expiry passed
401MERCHANT_AUTH_TOKEN_INVALIDBearer token missing / malformed / expired
403MERCHANT_API_IP_NOT_WHITELISTEDSource IP not in your allowlist
404PAYOUT_WALLET_NOT_FOUNDNo PAYOUT wallet — account not approved yet
404PAYOUT_NOT_FOUNDNo matching payout for this merchant
409PAYOUT_WALLET_SUSPENDEDWallet suspended or closed
409PAYOUT_INSUFFICIENT_BALANCESelected wallet balance < net amount
409INSUFFICIENT_WALLET_BALANCENo eligible wallet (smart routing)
409PAYOUT_WALLET_NOT_ELIGIBLEExplicit wallet_id not eligible
409PAYOUT_DUPLICATE_REFERENCEreference_id already used
409PAYOUT_IDEMPOTENCY_CONFLICTSame Idempotency-Key, different payload
409PAYOUT_CONCURRENT_UPDATESame key raced — retry after backoff
429RATE_LIMIT_EXCEEDEDSlow down
500INTERNAL_ERRORServer 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_key as 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_id uniqueness 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.