S

StaarPG Developer Docs

Payout Integration
API v3
Server-to-server

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.

Base URL: https://payout.staarpg.com AES-256-GCM Init → Execute → Resolve

1Quick start

Every payout is two calls: reserve, then execute. A third, read-only call checks status. That's the entire integration surface.

  1. InitSend beneficiary + amount, encrypted, signed. Get back a transaction_id valid for 5 minutes.
  2. ExecuteSend the transaction_id back with the identical amount / account / IFSC. Funds move.
  3. 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.

HeaderRequired onValue
API-KEYAll endpointsYour merchant API key
X-TimestampAll endpointsUnix seconds, within ±300s of server time
X-Merchant-RefAll endpointsMust exactly match merchant_ref in the body (or the transaction being queried)
X-SignatureInit onlySee Request signature — not sent on Execute, Status, or Balance
Content-TypePOST endpointsapplication/json — required for the body to parse at all
⚠️
Silent failure modeIf 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

FORMULA
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:

PLAINTEXT (BEFORE ENCRYPTION)
{"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
ciphertextbbc23a5ed52c237346dbd1a7527b42e6e1ae7578e7edf8bf2f8af43208d…372a3d72 (311 bytes — same length as plaintext, no padding)
tag (16B)eb8ac10a8c97c3233d4faf71984a10dd
ACTUAL HTTP BODY — POST THIS EXACT STRING
{"data":"4f3a1c9e0b7d2f8a11c9d4a2:bbc23a5e…372a3d72:eb8ac10a8c97c3233d4faf71984a10dd"}
ℹ️
Feed this exact key/nonce/plaintext into your own AES-256-GCM implementation and you should get this exact ciphertext and tag back — a good sanity check before pointing your code at live credentials. This byte-for-byte constraint only applies to reproducing this example: your own real request JSON can use any key order or whitespace — AES-GCM encrypts raw bytes with no concept of JSON structure, and the server parses whatever valid JSON comes out the other end regardless of field order.

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 / responseWebhook payload
Keysha256(api_key + hash_key)sha256(hash_key) — no api_key
Wire formatnonce_hex : ct_hex : tag_hexbase64(nonce ‖ tag ‖ ciphertext)
Where you'll see itInit, Execute, Status, BalanceCallback POSTs to your webhook_url
🚫
Reuse your API decrypt function on a webhook and it will fail every time.Different key material and a different byte layout (colon-delimited hex vs. a single base64 blob) mean the two are not interchangeable in either direction.

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.

SIGNATURE FORMULA
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()
⚠️
body_hash is over the bytes on the wire — not your plaintext.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_hash51c93666d2d1c2d9594e53c4fcde651a301b3d5046d307ad1d78ee2ddff77015
messagedemo_5f8a2c91|1757260800|ORDER-20260907-0142|670.00|51c93666d2d1c2d9594e53c4fcde651a301b3d5046d307ad1d78ee2ddff77015
X-Signature795c071ce19f1d374c72438858ab9b7fa5586faf894d68bc34dfa5244cd28751
✓
If your implementation produces the same 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/

#CheckOn failure
01Rate limit (max 10 req/s per IP)103
02X-Timestamp within ±5 min108
03X-Merchant-Ref header present104
04Merchant & hash_key resolved101
05Payload decrypts (bad format, wrong key, or missing body)110
06All required fields present104
07Header X-Merchant-Ref == body merchant_ref104
08Beneficiary field formats (name, account, IFSC, bank)104
09transfer_type is IMPS/NEFT/RTGS/UPI104
10Amount parses (positive, ≤2 decimals)106
11Currency supported107
12Route resolved, active, amount in its limits (route_name lookup on own-route, or auto-select on platform-route)105
13X-Signature verified (the earliest point a signature is even checked)109
14Fraud check (FUSE)112
15Wallet balance (own-route only — platform-route has no per-call wallet check here)100
16merchant_ref not already used102
17transaction_id issued000

POST /payout/ (execute)

#CheckOn failure
01Rate limit103
02X-Timestamp within ±5 min108
03X-Merchant-Ref header present104
04Payload decrypts110
05transaction_id present111
06Required fields present (amount, transfer_type, beneficiary_account, beneficiary_ifsc)104
07Amount parses106
08transaction_id exists, unused, unexpired111
09Fields match Init exactly (amount, transfer_type, beneficiary_account, beneficiary_ifsc)104 / 106
10Wallet debited, bank call dispatched001
ℹ️
Execute takes no 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.

01
POST /payout/init/

Validates beneficiary, route, wallet balance. No funds move. Returns transaction_id (TTL 300s).

02
POST /payout/

Consumes transaction_id. Debits wallet. Returns Pending immediately; bank call runs async.

03
Async — bank leg

Acquirer processes the transfer. Final state arrives via webhook.

04
GET /payout/status/…

Read-only reconciliation. Use if a webhook is delayed or missed.

⚠️
A retry after a rejected or expired Init requires a new 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).

REQUIRED BODY FIELDS
uid, mobile, merchant_ref, amount, currency,
transfer_type, route_name,
beneficiary_name, beneficiary_account,
beneficiary_ifsc, beneficiary_bank
// optional: email, upi, beneficiary_mobile
HEADERS
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"}
DECRYPTED RESPONSE — 000 SUCCESS
{"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.

REQUIRED BODY FIELDS
transaction_id, amount, transfer_type,
beneficiary_account, beneficiary_ifsc
// each must equal what you sent to /init/, exactly
DECRYPTED PLAINTEXT (BEFORE RE-ENCRYPTING)
{"transaction_id":"110709070745213648","amount":"670.00","transfer_type":"IMPS",
 "beneficiary_account":"409002010584125","beneficiary_ifsc":"UBIN0822116"}
DECRYPTED RESPONSE — ALWAYS 001 ON A CLEAN CALL
{"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."}
ℹ️
This is not a transient state you need to inspect twice — Execute always replies Pending on a successful call. The final outcome (000/003/004) only ever shows up on the webhook or a later Status call.

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.

DECRYPTED RESPONSE ONCE RESOLVED
{"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.

DECRYPTED RESPONSE
{"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.

AttemptDelay before this attempt
1Immediate
230 seconds after attempt 1
32 minutes after attempt 2
410 minutes after attempt 3
5 (final)1 hour after attempt 4
⚠️
Be idempotent.If your handler returns 200 but the response never reaches us (network drop), we retry anyway — you can receive the same event twice. Key your processing off pg_ref and treat a repeat as a no-op.
ℹ️
Circuit breaker.Five consecutive delivery failures to your endpoint within a 10-minute window pause all webhook delivery to you for 5 minutes. If you're not receiving webhooks during an incident on your side, this is why — it self-clears; no action needed from us.

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.

HEADERS
Content-Type:         application/json
X-Webhook-Timestamp:  1757231273
X-Pg-Ref:              110726090713045392
🚫
There is no signature header.Despite the name pattern, we do not send 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.
HTTP BODY — EXACT SHAPE
{"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)
ℹ️
Note the ordering inside the blob — nonce (12 bytes) then tag (16 bytes) then ciphertext — tag comes before the ciphertext here, the reverse of where it sits in the API's colon-delimited wire format. Slice at fixed byte offsets 0:12, 12:28, 28: — don't assume the same layout as the request/response scheme.

Decrypted payload — event: payout.status_update

JSON
{
  "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.

🔁
Test delivery end-to-end any time with 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.

DECRYPTED PENDING
{
  "status": "success",
  "response_code": "001",
  "data": { "pg_ref": "...", "merchant_ref": "...", "status": "Pending", "bank_ref": "", "amount": "670.00" },
  "message": "Transaction is being processed..."
}
ℹ️
A status check made within 30 seconds of Execute also returns this shape — that's a cooldown, not an error. Wait, then re-poll, or wait for the webhook.

10Field formats

Malformed values fail validation before anything else runs (steps 8/9/10 above) — worth matching exactly, especially amount and IFSC.

FieldPatternNotes
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_name1–100 chars, letters/space/&/./-/'No digits — a name like "Store 24" will be rejected.
beneficiary_bank1–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_typeenumOne 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

CodeKindMeaning
000successTransaction successful at bank
001pendingTransaction pending / being processed
002pendingInitiated at bank, awaiting confirmation
003terminalFailed at bank
004terminalRefunded to wallet
100errorInsufficient wallet balance
101errorInternal error — safe to retry
102errorDuplicate merchant_ref
103errorRate limited
104errorMissing field, or a mismatch (e.g. X-Merchant-Ref vs. body)
105errorRoute not found, inactive, or amount outside its limits
106errorInvalid amount, or amount mismatch vs. Init
107errorCurrency not supported
108errorX-Timestamp missing or outside ±5 min window
109errorX-Signature verification failed
110errorPayload decrypt failed — bad format, wrong key, or missing body
111errortransaction_id missing, expired, already used, or not found
112errorBlocked — fraud risk
404errorNo transaction matches that pg_ref / merchant_ref

12Common integration mistakes

1
Branching on HTTP status instead of response_code.Every response is HTTP 200. A "failed" transaction and a validation rejection both arrive as 200 — the outcome lives entirely inside the decrypted body.
2
Using the API decrypt function on a webhook.Different key, different wire format. They need two separate implementations.
3
Signing the plaintext instead of the encrypted body.body_hash in the signature is over the bytes actually POSTed — after encryption.
4
Sending a different amount to Execute than to Init.Even a formatting difference — "670" vs "670.00" — is treated as a mismatch and rejected with 106.
5
Reusing an expired or already-used transaction_id.It's single-use and expires 5 minutes after Init. A rejected Init also spends the merchant_ref — retry with a new one.
6
Treating Pending as Failed.Once a wallet debit happens, we never report failure for that call again. Pending means poll Status or wait for the webhook — not retry.
7
Waiting for an X-Webhook-Signature header.It doesn't exist. A successful decrypt is the authenticity check — don't build a code path that rejects genuine webhooks for lacking a header we never send.
8
Testing checks out of order.Checks run in a fixed sequence and stop at the first failure. A signature test done with an amount outside the route's limit will always show 105, never 109 — expected, not a bug on either side.

13Get help

✅
A UAT sign-off is required before production traffic.