Payout API v3
Server-to-server payouts with encrypted payloads, signed requests, and a single response envelope for every outcome. HTTP status is always 200 — the real outcome always lives in response_code, inside the body.
1Quick start
Every payout is two calls: reserve, then execute. A third, read-only call checks status. That's the entire integration surface.
- InitSend beneficiary + amount, encrypted, signed. Get back a
transaction_idvalid for 5 minutes. - ExecuteSend the
transaction_idback with the identical amount / account / IFSC. Funds move. - ResolveListen for the webhook, or poll Status. Read outcome from
response_code, never from the HTTP status code.
2Authentication & headers
Every request — including reads — carries these four headers. X-Signature is the one exception: it's required on Init only.
| Header | Required on | Value |
|---|---|---|
API-KEY | All endpoints | Your merchant API key |
X-Timestamp | All endpoints | Unix seconds, within ±300s of server time |
X-Merchant-Ref | All endpoints | Must exactly match merchant_ref in the body (or the transaction being queried) |
X-Signature | Init only | See Request signature — not sent on Execute, Status, or Balance |
Content-Type | POST endpoints | application/json — required for the body to parse at all |
Content-Type isn't exactly application/json, the server can't read your body. You'll get response_code 110 ("failed to decrypt") even though nothing was wrong with your encryption — there was simply no body to decrypt.3Encryption
Two independent schemes exist in this API. They share a name (AES-256-GCM) and nothing else. Confusing them is the single most common integration failure we see.
Request & response payloads
Every POST body and every response body (on success) is wrapped as {"data": "<encrypted>"}.
Key derivation & wire format
key = sha256(api_key + hash_key) // 32 raw bytes nonce = random(12 bytes) wire = nonce.hex() + ":" + ciphertext.hex() + ":" + tag.hex()
Decrypting a response uses the identical key and format — one implementation handles both directions.
✓ Worked example — illustrative demo credentials, round-trip verified
Using illustrative credentials api_key = demo_5f8a2c91, hash_key = demo_hashkey_b73e19 — never your live keys:
{"amount":"670.00","beneficiary_account":"409002010584125","beneficiary_bank":"UNION BANK OF INDIA","beneficiary_ifsc":"UBIN0822116","beneficiary_name":"SYEDSURAJ","currency":"INR","merchant_ref":"ORDER-20260907-0142","mobile":"7386462468","route_name":"IDFC_ROUTE_001","transfer_type":"IMPS","uid":"CUST-9042"}
| key (32B, hex) | 326a37359d8e7faab44fe19aff7db0d4864bff90b2b0a75bbd9b869517936a11 |
| nonce (12B) | 4f3a1c9e0b7d2f8a11c9d4a2 |
| ciphertext | bbc23a5ed52c237346dbd1a7527b42e6e1ae7578e7edf8bf2f8af43208d…372a3d72 (311 bytes — same length as plaintext, no padding) |
| tag (16B) | eb8ac10a8c97c3233d4faf71984a10dd |
{"data":"4f3a1c9e0b7d2f8a11c9d4a2:bbc23a5e…372a3d72:eb8ac10a8c97c3233d4faf71984a10dd"}
Webhooks use a different scheme
Webhook deliveries are encrypted separately, with a different key and a different wire format. You need a second decrypt function for them.
| API request / response | Webhook payload | |
|---|---|---|
| Key | sha256(api_key + hash_key) | sha256(hash_key) — no api_key |
| Wire format | nonce_hex : ct_hex : tag_hex | base64(nonce ‖ tag ‖ ciphertext) |
| Where you'll see it | Init, Execute, Status, Balance | Callback POSTs to your webhook_url |
4Request signature
Init requests must carry X-Signature — a keyed SHA-256 over the request metadata plus a hash of the exact body you send.
enc_key = sha256(api_key + hash_key).hexdigest() body_hash = sha256(request_body_bytes).hexdigest() message = api_key + "|" + timestamp + "|" + merchant_ref + "|" + amount + "|" + body_hash X-Signature = sha256(message + enc_key).hexdigest()
request_body_bytes is the literal HTTP body the server reads off the socket: the JSON {"data": "nonce:ct:tag"} string, after encryption. Hash your pre-encryption payload here by mistake and every signature check fails with 109, silently — the two byte sequences never match.Practical rule: encrypt first, keep the exact string you're about to POST, hash that string, sign, then send it unchanged. Don't let your HTTP client re-serialize the body after you've hashed it.
✓ Worked example — continues the payload above
Same request: api_key = demo_5f8a2c91, hash_key = demo_hashkey_b73e19, timestamp = 1757260800, merchant_ref = ORDER-20260907-0142, amount = 670.00. The body hashed below is the literal {"data":"…"} string — not the plaintext.
| enc_key (hex) | 326a37359d8e7faab44fe19aff7db0d4864bff90b2b0a75bbd9b869517936a11 |
| body_hash | 51c93666d2d1c2d9594e53c4fcde651a301b3d5046d307ad1d78ee2ddff77015 |
| message | demo_5f8a2c91|1757260800|ORDER-20260907-0142|670.00|51c93666d2d1c2d9594e53c4fcde651a301b3d5046d307ad1d78ee2ddff77015 |
| X-Signature | 795c071ce19f1d374c72438858ab9b7fa5586faf894d68bc34dfa5244cd28751 |
body_hash and X-Signature from that exact body string, your signing code is correct — any real-request failure at that point is a key or credential problem, not a formula problem.5Validation order
Checks run top-to-bottom and stop at the first failure. A failure earlier in the list masks anything that would have failed later — testing "does my bad signature get rejected" with an amount outside the route's limit will always show 105, never 109.
POST /payout/init/ and /payout/bank/init/
| # | Check | On failure |
|---|---|---|
| 01 | Rate limit (max 10 req/s per IP) | 103 |
| 02 | X-Timestamp within ±5 min | 108 |
| 03 | X-Merchant-Ref header present | 104 |
| 04 | Merchant & hash_key resolved | 101 |
| 05 | Payload decrypts (bad format, wrong key, or missing body) | 110 |
| 06 | All required fields present | 104 |
| 07 | Header X-Merchant-Ref == body merchant_ref | 104 |
| 08 | Beneficiary field formats (name, account, IFSC, bank) | 104 |
| 09 | transfer_type is IMPS/NEFT/RTGS/UPI | 104 |
| 10 | Amount parses (positive, ≤2 decimals) | 106 |
| 11 | Currency supported | 107 |
| 12 | Route resolved, active, amount in its limits (route_name lookup on own-route, or auto-select on platform-route) | 105 |
| 13 | X-Signature verified (the earliest point a signature is even checked) | 109 |
| 14 | Fraud check (FUSE) | 112 |
| 15 | Wallet balance (own-route only — platform-route has no per-call wallet check here) | 100 |
| 16 | merchant_ref not already used | 102 |
| 17 | transaction_id issued | 000 |
POST /payout/ (execute)
| # | Check | On failure |
|---|---|---|
| 01 | Rate limit | 103 |
| 02 | X-Timestamp within ±5 min | 108 |
| 03 | X-Merchant-Ref header present | 104 |
| 04 | Payload decrypts | 110 |
| 05 | transaction_id present | 111 |
| 06 | Required fields present (amount, transfer_type, beneficiary_account, beneficiary_ifsc) | 104 |
| 07 | Amount parses | 106 |
| 08 | transaction_id exists, unused, unexpired | 111 |
| 09 | Fields match Init exactly (amount, transfer_type, beneficiary_account, beneficiary_ifsc) | 104 / 106 |
| 10 | Wallet debited, bank call dispatched | 001 |
X-Signature — it's not in the header list at all, at any step. Sending one is harmless; it's simply never read.6Transaction flow
A transaction_id from Init is single-use and expires 5 minutes after issue. Execute must reuse it — and must repeat the same amount, transfer_type, beneficiary_account and beneficiary_ifsc from Init, exactly.
POST /payout/init/
Validates beneficiary, route, wallet balance. No funds move. Returns transaction_id (TTL 300s).
POST /payout/
Consumes transaction_id. Debits wallet. Returns Pending immediately; bank call runs async.
Async — bank leg
Acquirer processes the transfer. Final state arrives via webhook.
GET /payout/status/…
Read-only reconciliation. Use if a webhook is delayed or missed.
merchant_ref — the old one is spent the moment it's rejected.7Endpoints
POST https://payout.staarpg.com/api/v3/payout/init/
Own-route merchants. You supply route_name, mapped to your own bank account. Includes a wallet soft-check (step 15 above).
uid, mobile, merchant_ref, amount, currency,
transfer_type, route_name,
beneficiary_name, beneficiary_account,
beneficiary_ifsc, beneficiary_bank
// optional: email, upi, beneficiary_mobile
API-KEY: demo_5f8a2c91
X-Timestamp: 1757260800
X-Merchant-Ref: ORDER-20260907-0142
X-Signature: 795c071ce19f1d374c72438858ab9b7fa5586faf894d68bc34dfa5244cd28751
Content-Type: application/json
Body: {"data":"4f3a1c9e0b7d2f8a11c9d4a2:bbc23a5e…372a3d72:eb8ac10a8c97c3233d4faf71984a10dd"}
{"status":"success","response_code":"000",
"data":{"transaction_id":"110709070745213648","pg_ref":"110709070745213648","expires_in":300},
"message":"","timestamp":"1757260801"}
POST https://payout.staarpg.com/api/v3/payout/bank/init/
Platform-route merchants. Identical body to above, without route_name — an acquirer is auto-selected. No wallet-balance step exists on this path — billing is handled outside the per-call wallet check.
POST https://payout.staarpg.com/api/v3/payout/
Execute — the shared second call for both routing modes. No X-Signature header.
transaction_id, amount, transfer_type,
beneficiary_account, beneficiary_ifsc
// each must equal what you sent to /init/, exactly
{"transaction_id":"110709070745213648","amount":"670.00","transfer_type":"IMPS",
"beneficiary_account":"409002010584125","beneficiary_ifsc":"UBIN0822116"}
{"status":"success","response_code":"001",
"data":{"pg_ref":"110709070745213648","merchant_ref":"ORDER-20260907-0142",
"status":"Pending","bank_ref":"","amount":"670.00"},
"message":"Transaction is being processed. Poll https://payout.staarpg.com/api/v3/payout/status/110709070745213648/ for the final result."}
GET https://payout.staarpg.com/api/v3/payout/status/<pg_ref>/
No body, no X-Signature. pg_ref is the transaction_id from Init. X-Merchant-Ref must equal the transaction's own merchant_ref — a mismatch returns 104, not the transaction, so you can't probe another merchant's data by guessing a pg_ref.
{"status":"success","response_code":"000",
"data":{"pg_ref":"110709070745213648","merchant_ref":"ORDER-20260907-0142",
"status":"Success","bank_ref":"UTR2607071345921","amount":"670.00"},
"message":"Transaction successful"}
GET https://payout.staarpg.com/api/v3/payout/status/merchant/<merchant_ref>/
Same shape as above, keyed by your own merchant_ref — the fallback when Execute never returned a pg_ref (e.g. the response was lost before you read it).
GET https://payout.staarpg.com/api/v3/payout/balance/
Returns available and hold balance per active wallet currency.
{"status":"success","response_code":"000",
"data":{"merchant_id":"AM6054","wallets":[
{"currency":"INR","available_balance":"184230.50","hold_balance":"6700.00","wallet_type":"prepaid"}
]},"message":""}
8Webhooks
The definitive way to learn a transaction's final state. Fired once the bank leg resolves, and retried on delivery failure — don't rely on Status alone if you can receive these.
Delivery
We POST to your registered webhook_url. Your endpoint must reply with a literal HTTP 200 within 15 seconds — anything else (wrong status code, timeout, connection error) counts as a failed attempt and triggers a retry.
| Attempt | Delay before this attempt |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds after attempt 1 |
| 3 | 2 minutes after attempt 2 |
| 4 | 10 minutes after attempt 3 |
| 5 (final) | 1 hour after attempt 4 |
pg_ref and treat a repeat as a no-op.Request shape
Distinct from every API response — the encrypted value sits under the key encrypted, not data, and two plaintext fields ride alongside it unencrypted.
Content-Type: application/json X-Webhook-Timestamp: 1757231273 X-Pg-Ref: 110726090713045392
X-Webhook-Signature or any equivalent. Authenticity comes entirely from a successful AES-GCM decrypt — the auth tag only verifies if the payload was encrypted with your real hash_key. If it decrypts, it's genuine; there is no separate signature to check.{"encrypted":"<base64 blob>","pg_ref":"110726090713045392","timestamp":"1757231273"}
✓ Worked example — round-trip verified
| key = sha256(hash_key) | 68b8df9c1f83052bf1b8d0ddd1cc2fbae954144ab20a4b0581a6f70e6fe2bb72 |
| nonce (12B) | a1b2c3d4e5f60718293a4b5c |
| blob = base64(nonce‖tag‖ciphertext) | obLD1OX2BxgpOktcifQErI0lZQyz3zG9o+AbkCrwXUGu7LzkJazjBVh7RIwo…12LCKx3B3BZxJgAh4y8= (580 chars) |
Decrypted payload — event: payout.status_update
{
"event": "payout.status_update",
"data": {
"pg_ref": "110726090713045392",
"merchant_ref": "ORDER-20260907-0142",
"transaction_status": "Success",
"amount": "670.00",
"charges": "3.50",
"gst": "0.63",
"bank_ref": "UTR2607071345921",
"bank_remark": "Transaction successful",
"transfer_type": "IMPS",
"currency": "INR",
"transaction_mode": "API_V3",
"created_at": "2026-09-07T07:45:00+00:00",
"updated_at": "2026-09-07T07:47:53+00:00"
}
}
transaction_status here is the raw bank-facing status string (Success / Pending / Failed / Refund) — not the numeric response_code used elsewhere. Map it yourself if your system keys off codes.
POST https://payout.staarpg.com/api/v3/payout/webhook/test/ (max 2/min) — it fires one real encrypted webhook to your registered URL with fabricated transaction data, and its own response tells you the HTTP status your endpoint returned.9Response envelope
HTTP status is always 200, deliberately — CDN and infra layers only ever see a healthy response. Read response_code inside the body for the real outcome.
Success — encrypted
{"data": "<wire-format>"}Error — plain JSON
{status, response_code,
data, message, timestamp}Once a success response is decrypted, it unwraps to the same field set as the error shape — status, response_code, data, message, timestamp — so downstream parsing code never branches on outcome, only on whether decryption was attempted.
Pending is not failed
Once your wallet is debited on Execute, we never return a failure for that call again — an interruption after debit always resolves to response_code 001 / Pending, success-shaped and encrypted like any other success, telling you where to poll for the final state.
{
"status": "success",
"response_code": "001",
"data": { "pg_ref": "...", "merchant_ref": "...", "status": "Pending", "bank_ref": "", "amount": "670.00" },
"message": "Transaction is being processed..."
}
10Field formats
Malformed values fail validation before anything else runs (steps 8/9/10 above) — worth matching exactly, especially amount and IFSC.
| Field | Pattern | Notes |
|---|---|---|
amount | ^\d+(\.\d{1,2})?$ | String, positive, ≤2 decimals. No sign, no exponent, no thousands separator — "670.00", not "670" vs "670.0" mismatched between Init/Execute. |
beneficiary_ifsc | ^[A-Z]{4}0[A-Z0-9]{6}$ | Exactly 11 chars, uppercased before matching — lowercase input is accepted and normalized. |
beneficiary_account | ^\d{5,20}$ | Digits only, no leading/trailing spaces after trim. |
beneficiary_name | 1–100 chars, letters/space/&/./-/' | No digits — a name like "Store 24" will be rejected. |
beneficiary_bank | 1–100 chars, letters/space/&/./-/' | Same charset as beneficiary_name. |
mobile / beneficiary_mobile | ^\d{7,15}$ | Digits only, no country-code + prefix. |
currency | ^[A-Z]{3}$ | Must exist as a configured Currency — INR is the only one in general use. |
transfer_type | enum | One of IMPS, NEFT, RTGS, UPI — uppercased before matching. |
route_name | ^[\w\-.]{1,100}$ | Own-route Init only — omit entirely on /bank/init/, don't send empty string. |
11Response codes
| Code | Kind | Meaning |
|---|---|---|
| 000 | success | Transaction successful at bank |
| 001 | pending | Transaction pending / being processed |
| 002 | pending | Initiated at bank, awaiting confirmation |
| 003 | terminal | Failed at bank |
| 004 | terminal | Refunded to wallet |
| 100 | error | Insufficient wallet balance |
| 101 | error | Internal error — safe to retry |
| 102 | error | Duplicate merchant_ref |
| 103 | error | Rate limited |
| 104 | error | Missing field, or a mismatch (e.g. X-Merchant-Ref vs. body) |
| 105 | error | Route not found, inactive, or amount outside its limits |
| 106 | error | Invalid amount, or amount mismatch vs. Init |
| 107 | error | Currency not supported |
| 108 | error | X-Timestamp missing or outside ±5 min window |
| 109 | error | X-Signature verification failed |
| 110 | error | Payload decrypt failed — bad format, wrong key, or missing body |
| 111 | error | transaction_id missing, expired, already used, or not found |
| 112 | error | Blocked — fraud risk |
| 404 | error | No transaction matches that pg_ref / merchant_ref |
12Common integration mistakes
body_hash in the signature is over the bytes actually POSTed — after encryption."670" vs "670.00" — is treated as a mismatch and rejected with 106.105, never 109 — expected, not a bug on either side.