Overview

The Finzep Payout API lets your system send money to a bank account (IMPS / NEFT / RTGS) or a UPI ID from your Finzep wallet, and then check what happened. Two calls do the job: POST /API/Payout (create a payout) and POST /API/Payout/Status (ask for its current status).

Every request and every answer is signed and encrypted. You never send the plain JSON on the wire:

Your system Finzep ----------- ------ plain JSON --encrypt (our public key) + sign (your private key)--> verify signature, decrypt, run the payout plain JSON <--verify signature (our public key) + decrypt (yours)-- encrypt (your public key) + sign (our private key)
Encryption is mandatory. A plain, unencrypted request is refused with HTTP 400 and errorCode 1012. Ready-made client code for Node.js, C#, Python and Java and a Postman collection are included, so you do not have to write the cryptography yourself.

Quick start

  1. Log in to the Finzep portal as your API user, open API Access and click Generate key. Copy the fzp_... API key - it is shown only once.
  2. Create your RSA key pair (keep the private key secret):
    node fzp-client.js keygen .  or  python fzp_client.py keygen .  or  java FzpClient.java keygen .  or  OpenSSL (see below).
  3. Register the public key: in API Access, section "Encryption keys", paste client-public.pem and click Register key. You get a clientKeyId such as ck_1a2b3c4d5e6f.
  4. Fetch our public key: GET /API/PublicKey returns serverKeyId and publicKeyPem. Check its fingerprint with the one shown in the portal.
  5. (Optional) Whitelist your server's IP in API Access, section "IP whitelist".
  6. Send a payout with one of the samples or the Postman collection, then check its status.
  7. (Optional) Receive status updates by webhook: register your https URL in API Access → Webhook (see Webhooks) instead of polling.

Getting your credentials

WhatWhere it comes fromUsed for
API key fzp_... (68 characters)Portal → API Access → Generate / Regenerate. Shown once; only a hash is stored. Re-entering your password is required.Header X-Api-Key: identifies your account
Your key pair (RSA 2048-8192 bits, 3072 recommended)You generate it. The private key never leaves you. Register only the public key (PEM, -----BEGIN PUBLIC KEY-----) in API Access.Sign requests, decrypt answers (private) / let us verify and encrypt for you (public)
clientKeyId ck_...Returned when you register your public key. Up to 5 active keys per account.Tells us which of your public keys to use
Our public key + serverKeyId sk_...GET /API/PublicKey (public) or the portal.Encrypt requests, verify our signature

Generate a key pair with OpenSSL if you prefer:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out client-private.pem
openssl rsa -in client-private.pem -pubout -out client-public.pem
Never share or upload client-private.pem. The portal refuses a private key if you paste one by mistake. If you think it leaked, revoke the public key in API Access at once and register a new pair.

Authentication & headers

HeaderValue
X-Api-KeyYour API key, fzp_...required
X-Encrypted1required - tells the server the body is an envelope
Content-Typeapplication/jsonrequired

There is no login, cookie or token exchange. If the key is missing, wrong, revoked or its owner is inactive you get 401; if your server's IP is not on your whitelist you get 403 (see Errors).

The encrypted envelope

The body of every request (and every answer) is one JSON object, the envelope. Your normal JSON is encrypted into payload:

{
  "clientKeyId": "ck_1a2b3c4d5e6f",
  "serverKeyId": "sk_9f8e7d6c5b4a",
  "timestamp": "2026-10-09T12:00:00Z",
  "nonce": "...",
  "encryptedKey": "...",
  "iv": "...",
  "payload": "<aapke plain JSON ka AES encrypted form>",
  "signature": "..."
}

i.e. payload is your plain JSON, AES-encrypted. All binary values are standard Base64.

FieldWhat it is
clientKeyIdThe id of YOUR registered public key (ck_...). The signature is checked with it.
serverKeyIdThe id of OUR public key you encrypted for (sk_..., from /API/PublicKey).
timestampUTC time you created the message, ISO-8601 without fractions, e.g. 2026-10-09T12:00:00Z. Must be within 5 minutes of our clock.
nonce16 random bytes, Base64. New for every message; a nonce is accepted only once.
encryptedKeyThe one-time AES-256 key, encrypted with our public key (RSA-OAEP, SHA-256).
iv12 random bytes, Base64 (AES-GCM IV).
payloadYour JSON, encrypted with AES-256-GCM; the 16-byte authentication tag is appended to the ciphertext.
signatureYour RSA-PSS (SHA-256) signature over the message (path, ids, timestamp, nonce and the encrypted fields).

The answer uses exactly the same envelope shape (encrypted for you, signed by us). The exact algorithm is in Encryption specification; the sample libraries do it for you.

A complete call on the wire

POST /API/Payout HTTP/1.1
Host: your-finzep-host
X-Api-Key: fzp_9d1c...e07a
X-Encrypted: 1
Content-Type: application/json

{"clientKeyId":"ck_1a2b3c4d5e6f","serverKeyId":"sk_9f8e7d6c5b4a","timestamp":"2026-10-09T12:00:00Z",
 "nonce":"c3VwZXItcmFuZG9tLTE2Ynl0ZQ==","encryptedKey":"b6Rk...(512 chars)...","iv":"mZ1xQ0ZrUjRzV1E4",
 "payload":"p0eC...(your JSON, encrypted)...Qw==","signature":"Jf9a...(512 chars)..."}

HTTP/1.1 200 OK
X-Encrypted: 1
Content-Type: application/json

{"clientKeyId":"ck_1a2b3c4d5e6f","serverKeyId":"sk_9f8e7d6c5b4a","timestamp":"2026-10-09T12:00:01Z",
 "nonce":"...","encryptedKey":"...","iv":"...","payload":"...(the answer, encrypted)...","signature":"..."}

POST /API/Payout

Creates a payout. The plain JSON you encrypt into payload:

{
  "APIRequestID": "REQ-0001",
  "TransMode": "IMPS",
  "Amount": 100.00,
  "AccountNo": "1234567890123",
  "IFSC": "HDFC0000123",
  "BeneName": "Ramesh Kumar",
  "BeneMobile": "9876543210",
  "SenderName": "Suresh Sharma",
  "SenderMobile": "9999999999",
  "SenderEmail": "sender@example.com"
}
FieldTypeRules
APIRequestIDstringrequiredYour own unique reference, max 100 characters. A repeat of an id you already used is refused (code 150) - use the Status call to see the earlier payout.
TransModestringrequiredIMPS, NEFT, RTGS or UPI. A mode that is switched off for you gives code 224.
AmountnumberrequiredAmount the beneficiary receives, at least 1, two decimals. Your wallet is debited this amount plus your slab charge (see Rules).
AccountNostringrequiredBank account number (5-50 characters). For UPI it is the VPA and must contain @ (e.g. ramesh@okhdfc).
IFSCstringbank modesExactly 11 characters. Not needed for UPI (send null or omit). IFSCs of banks merged by RBI are refused (code 122); an IFSC of an unsupported bank gives code 220.
BeneNamestringrequiredBeneficiary name, max 150.
BeneMobilestringoptionalMax 15 characters.
SenderNamestringrequiredMax 150.
SenderMobilestringrequiredExactly 10 digits.
SenderEmailstringoptionalMax 150.

The answer (decrypted)

{
  "success": true,
  "status": 2,
  "statusName": "Success",
  "message": "Transaction successful.",
  "apiRequestID": "REQ-0001",
  "transactionId": "S26100912242010EDA3C",
  "utr": "UTR12345678",
  "errorCode": 0,
  "referenceId": null
}
FieldMeaning
successtrue only when the payout is Success. A Pending payout is false with errorCode 0 - it is not an error, see Statuses.
status / statusName1 Pending, 2 Success, 3 Failed. A refused request is reported as 3 Failed with the reason in errorCode / message.
messageHuman readable text. Do not parse it - use errorCode.
apiRequestIDYour APIRequestID, echoed back.
transactionIdOur transaction id (e.g. S26100912242010EDA3C). null when the request was refused and no payout was created. Quote it to support.
utrThe bank's reference number once the payout is Success (can be null before that).
errorCode0 = no error, otherwise see Errors.
referenceIdOnly for errorCode 143 (system error): quote this number to support.

POST /API/Payout/Status

Returns the current state of one of your own payouts. Plain JSON to encrypt:

{ "APIRequestID": "REQ-0001" }

The answer has the same fields as the payout answer. For a payout that is still Pending, our server asks the bank and updates the payout, so call this to resolve a Pending payout. An unknown id gives errorCode 1001. You can only see payouts created by your own account.

GET /API/PublicKey

Public (no key, no encryption) - returns our currently active public key:

{
  "serverKeyId": "sk_9f8e7d6c5b4a",
  "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n",
  "fingerprint": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "keyBits": 3072,
  "specification": "FZP1", "keyWrap": "RSA-OAEP-SHA256", "payload": "AES-256-GCM", "signature": "RSA-PSS-SHA256"
}

The fingerprint is the SHA-256 of the key's SubjectPublicKeyInfo, lower-case hex. Compare it with the value in the portal (API Access) before trusting a key. Fetch the key once and cache it; fetch again when we tell you the key was rotated.

Statuses & lifecycle

statusMeaningWhat to do
1 PendingAccepted and money is being sent; the bank has not confirmed yet. Your wallet is already debited.Wait, then call /API/Payout/Status. Keep polling until it is Success or Failed (see below).
2 SuccessThe bank confirmed. utr is set.Done. Final.
3 FailedNot paid. If a payout was created, the charged amount is refunded to your wallet automatically. A refused request (validation, balance ...) is also reported as Failed but nothing was debited.Read errorCode. Fix the cause and send a new payout with a new APIRequestID.

Recommended polling

Webhooks

Instead of polling /API/Payout/Status, you can give us an https URL. Finzep calls it (HTTP POST) when a payout or refund changes state after your original request. Webhooks are optional, and they use the same signed + encrypted envelope as the API.

When we call you

EventSent when
payout.successA Pending payout became Success because the admin marked it, or because a status check (your own /API/Payout/Status call, or the admin's Status button) found the final result.
payout.failedA Pending payout became Failed the same way. The charged amount has already been refunded to your wallet.
refund.acceptedThe admin accepted your refund request for a Success payout; the charged amount was credited to your wallet.
refund.rejectedThe admin rejected your refund request (the payout stays Success).
webhook.testYou pressed "Send test event" in the portal.
A payout that is already final in the answer to /API/Payout does not trigger a webhook - you have that result. Webhooks tell you about changes that happen later. Webhooks are an addition, not a replacement: if you have not heard about a Pending payout, you can always call /API/Payout/Status.

Setup

  1. You need a registered public key (API Access, section "Encryption keys") - every webhook is encrypted for your newest active public key.
  2. In API Access → Webhook enter your URL (must be https, a public internet address - no localhost, no private IPs, no redirects), tick Enabled, click Save, then Send test event.
  3. The list below the form shows every delivery with its status, attempts and your server's HTTP answer; View shows the payload and Resend sends an event again.

What we send

POST https://your-server.example.com/finzep/webhook
Content-Type: application/json
X-Encrypted: 1
X-Webhook-Event: payout.success
X-Webhook-Id: 0b1f6c52-3a7d-4f0e-9c1a-6f2f1d5b7a10
X-Webhook-Attempt: 1
User-Agent: Finzep-Webhook/1.0

{ "clientKeyId": "ck_1a2b3c4d5e6f", "serverKeyId": "sk_9f8e7d6c5b4a", "timestamp": "2026-10-09T12:00:00Z", "nonce": "...",
  "encryptedKey": "...", "iv": "...", "payload": "<the event JSON, AES-encrypted>", "signature": "..." }

The body is the same envelope as in the API: encrypted for your public key (clientKeyId says which one) and signed with our private key (serverKeyId). X-Webhook-Id is the event id; it is part of the signed text, so it cannot be changed.

The event (decrypted)

{
  "event": "payout.success",
  "eventId": "0b1f6c52-3a7d-4f0e-9c1a-6f2f1d5b7a10",
  "createdAt": "2026-10-09T12:00:00Z",
  "source": "admin",
  "success": true,
  "status": 2,
  "statusName": "Success",
  "message": "Transaction successful.",
  "errorCode": 0,
  "apiRequestID": "REQ-0001",
  "transactionId": "S26100912242010EDA3C",
  "utr": "UTR12345678",
  "amount": 100.00,
  "refund": null
}

For a refund event refund is filled: { "status": "Accepted", "amount": 103.00, "remark": "verified, refunding" } (amount = the charged amount credited to your wallet; null when rejected).

FieldMeaning
event, eventIdThe event type and its unique id (a GUID). Use eventId to ignore duplicates - an event can be delivered more than once.
sourceadmin (the admin settled the payout), status_check (found by a status check), vendor_callback (the bank/vendor reported the result), refund, test.
success, status, statusName, message, errorCodeSame meaning as in the payout answer (success for a refund event means "accepted").
apiRequestID, transactionId, utr, amountYour reference, our transaction id, the bank reference (Success payouts) and the payout amount.

Verifying and decrypting

  1. Read the raw body and the header X-Webhook-Id.
  2. signedText = FZP1 \n WEBHOOK \n <X-Webhook-Id> \n clientKeyId \n serverKeyId \n timestamp \n nonce \n encryptedKey \n iv \n payload (joined with a single \n; the same layout as in the specification, with WEBHOOK and the event id on lines 2-3).
  3. Verify signature with our public key (RSA-PSS-SHA256). Reject the call if it does not verify.
  4. Decrypt exactly as for an API answer: RSA-OAEP unwraps the AES key with your private key, AES-256-GCM opens payload.
  5. Optionally check that timestamp is recent (we create a fresh envelope for every attempt).

The client files in Samples already do this: openWebhook(...) (Node.js), OpenWebhook(...) (C#), open_webhook(...) (Python), openWebhook(...) (Java). Complete, tested receivers: webhook-receiver.js (Node.js), webhook_receiver.py.txt (Python), WebhookReceiver.java.txt (Java).

const { openWebhook } = require('./fzp-client');

// inside your HTTPS handler: `body` = raw request text, `req.headers['x-webhook-id']` = event id
const event = JSON.parse(openWebhook({ body, eventId: req.headers['x-webhook-id'],
                                       clientPrivateKeyPem, serverPublicKeyPem: pk.publicKeyPem }));   // throws if the signature is wrong
if (!alreadyHandled(event.eventId)) { process(event); }       // event.event, event.transactionId, event.statusName, event.utr ...
res.writeHead(200); res.end('ok');                            // any 2xx = delivered
// ASP.NET Core minimal API; `client` is the FzpClient from the Samples section
app.MapPost("/finzep/webhook", async (HttpRequest req) =>
{
    var body = await new StreamReader(req.Body).ReadToEndAsync();
    var json = client.OpenWebhook(body, req.Headers["X-Webhook-Id"]!);      // throws CryptographicException if the signature is wrong
    var evt = JsonDocument.Parse(json).RootElement;
    if (!AlreadyHandled(evt.GetProperty("eventId").GetString()!)) Process(evt);   // evt "event", "transactionId", "statusName", "utr" ...
    return Results.Ok();                                                    // any 2xx = delivered
});
from fzp_client import open_webhook
import json

# inside your HTTPS handler: `body` = raw request text, `event_id` = the X-Webhook-Id header
event = json.loads(open_webhook(body, event_id, client_private_pem, server_public_pem))   # raises if the signature is wrong
if not already_handled(event["eventId"]):
    process(event)            # event["event"], event["transactionId"], event["statusName"], event["utr"] ...
# answer HTTP 200 - any 2xx = delivered
// inside your HTTPS handler: `body` = raw request text, `eventId` = the X-Webhook-Id header
String json = FzpClient.openWebhook(body, eventId, clientPrivate, serverPublic);   // throws if the signature is wrong
String id = FzpClient.field(json, "eventId");
if (!alreadyHandled(id)) process(json);       // fields: event, transactionId, statusName, utr ...
// answer HTTP 200 - any 2xx = delivered

Your answer, retries and duplicates

Errors & codes

HTTP status

HTTPWhen
200The call was processed. This includes refused payouts (success:false with an errorCode) and Pending / Failed payouts. Look at errorCode and status, not only at the HTTP code.
400The decrypted JSON is invalid or a field is missing (errorCode 102), or the envelope/crypto is wrong (1008-1012).
401API key missing / wrong / revoked (211) or signature / key problem (1009, 1013).
403Your server's IP is not on your whitelist (211).
429Too many wrong attempts (1007). Wait ~10 minutes.
500Unexpected system error: errorCode 143 with a referenceId. The error is logged on our side; quote the reference to support. Check the status of the payout before retrying.
Encrypted or plain? Once an envelope is verified, every answer - success or business error - is encrypted. Problems found before a message can be trusted (API key, IP, rate limit, malformed envelope, signature, timestamp, replay, unknown key) come back as ordinary plain JSON with the same shape (success, errorCode, message) and contain no payout data.

Payout and status codes

errorCodeMeaningWhat to do
0No error (Success or Pending)
102Invalid or missing field(s) - the message lists them. Also: SenderMobile not a 10-digit number; kharab/empty JSON.Fix the request
104Invalid VPA (UPI account must contain @)Fix AccountNo
121IFSC missing or not 11 characters (bank modes)Fix IFSC
122IFSC suspended by RBI (bank merger)Use the new IFSC
128Insufficient wallet balance (or an invalid charge)Top up your wallet
129Commercials (slab / tax) not defined for you for this modeContact your account manager
136Amount must be at least 1Fix Amount
143System error - includes referenceIdCheck the status, then contact support with the reference
145Invalid account numberFix AccountNo
150Repeat APIRequestID, or the same account + amount within 1 minuteCheck the status of the earlier request; do not resend
152Service for this mode temporarily unavailable or busy (also when a payout was created but could not be sent: the amount is refunded)Try again a little later with a new APIRequestID (if a transactionId came back, that earlier payout is Failed and refunded)
211Access: API key invalid / revoked, user inactive or not found, IP not whitelisted, role not allowedCheck the key and the IP whitelist
220The bank of this IFSC is not supportedUse another account
223KYC is not approved for your accountComplete KYC in the portal
224Invalid or inactive payment modeUse IMPS, NEFT, RTGS or UPI, and only a mode enabled for you
301APIRequestID is requiredSend it
302Transaction failed; the charged amount was refunded to your walletRead the status, then send a new payout if needed
1001No payout found for this APIRequestID (Status call)Check the id

Encryption and access codes

errorCodeHTTPMeaningTypical cause / fix
211401 / 403API key invalid / missing / revoked, or IP not whitelistedWrong X-Api-Key; add your server IP to the whitelist
1007429Too many invalid attempts10 wrong API keys from one IP, or 10 bad envelopes for your account, within 10 minutes. Wait and fix the cause.
1008400Invalid envelope or decryption failedA field is missing, not Base64, or you encrypted for the wrong key / with the wrong algorithm
1009401Signature verification failedSigned with another key than the registered one, wrong "signed text" (e.g. wrong path), or the message was altered
1010400Timestamp missing or outside the 5-minute windowYour server clock is wrong (use NTP) or the timestamp is not UTC / ISO-8601
1011400Nonce already used (replay)Generate a new random nonce for every message; never re-send an old envelope
1012400Encrypted requests are mandatoryThe X-Encrypted: 1 header or the envelope is missing
1013401Unknown, expired or revoked clientKeyId / serverKeyIdRegister your key; fetch our current key from /API/PublicKey

Rules & limits

TopicRule
Wallet chargeYour wallet is debited Amount + your slab charge (and GST where applicable) when the payout is accepted. If the payout fails, the same charged amount is refunded to the wallet automatically.
DuplicatesOne APIRequestID can be used once per account. The same account number + amount within 60 seconds is also refused (150).
ClockYour timestamp must be within ±5 minutes of ours. Keep your server on NTP.
Replay protectionA nonce is accepted once (remembered for 10 minutes). Each request needs a fresh nonce, timestamp, IV and AES key.
IP whitelistOptional. With no IP listed your key works from anywhere; once you list one or more IPv4/IPv6 addresses (max 20), calls from any other address are refused (403). Manage it in API Access.
Rate limits on wrong credentials10 wrong API keys from one IP, or 10 bad envelopes (signature / decryption / key) for one account, within 10 minutes block further attempts for the rest of that window (429).
Key sizesYour RSA key: 2048-8192 bits (3072 recommended). Ours is 3072.
Our key rotationWhen we rotate our key pair the old public key keeps working for 30 days. Fetch /API/PublicKey again and switch to the new serverKeyId in that time.
Your key rotationRegister the new public key while the old one is still active, switch your code over, then revoke the old one in API Access. Up to 5 keys can be active at once.
SizeAn envelope can be at most 256 KB.

Encryption specification (FZP1)

Only needed if you write your own client; otherwise use the samples.

PartAlgorithm
Key wrapRSA-OAEP, hash SHA-256, MGF1 with SHA-256, empty label
PayloadAES-256-GCM, 12-byte IV, 16-byte tag appended to the ciphertext
SignatureRSA-PSS, hash SHA-256, MGF1 with SHA-256, salt length 32 bytes
EncodingStandard Base64 for all binary fields; UTF-8 for text

Building a request

  1. json = the plain request, e.g. {"APIRequestID":"REQ-1", ...}
  2. Generate aesKey (32 random bytes), iv (12), nonce (16); timestamp = now in UTC, yyyy-MM-ddTHH:mm:ssZ.
  3. aad = UTF-8 of timestamp + "\n" + nonce
  4. payload = Base64( AES-256-GCM-encrypt(aesKey, iv, json, aad) )  (ciphertext followed by the 16-byte tag)
  5. encryptedKey = Base64( RSA-OAEP-SHA256-encrypt(our public key, aesKey) )
  6. signedText = the lines below joined with a single \n (no trailing newline), each Base64 value exactly as it is sent:
    FZP1
    REQUEST
    <path, e.g. /API/Payout>
    <clientKeyId>
    <serverKeyId>
    <timestamp>
    <nonce>
    <encryptedKey>
    <iv>
    <payload>
  7. signature = Base64( RSA-PSS-SHA256-sign(your private key, UTF-8(signedText)) )
  8. POST the envelope with the headers from Authentication & headers.

The path is part of the signed text, so a request signed for /API/Payout/Status cannot be replayed against /API/Payout.

Webhooks use the same machinery in the other direction

A webhook envelope is built by us exactly like an answer, but its signed text has WEBHOOK on line 2 and the event id (the X-Webhook-Id header) on line 3 instead of a request nonce. See Webhooks.

Opening the answer

  1. signedText = the same layout but line 2 is RESPONSE and line 3 is the nonce of your request; lines 4-10 come from the answer's envelope.
  2. Verify signature with our public key (RSA-PSS-SHA256). If it does not verify, discard the answer.
  3. aesKey = RSA-OAEP-SHA256-decrypt(your private key, encryptedKey)
  4. json = AES-256-GCM-decrypt(aesKey, iv, payload without its last 16 bytes, tag = the last 16 bytes, aad = timestamp + "\n" + nonce of the answer)

Samples (Node.js, C#, Python, Java)

Each client file below is self-contained, tested against our server, and has the same command line interface: keygen, build-request, open-response. Download: fzp-client.js (Node.js 18+, no dependencies) · FzpClient.cs.txt (.NET 6+, rename to FzpClient.cs) · fzp_client.py.txt (Python 3.8+, pip install cryptography requests, rename to fzp_client.py) · FzpClient.java.txt (Java 11+, rename to FzpClient.java).

const fs = require('fs');
const { buildRequest, openResponse } = require('./fzp-client');

const base = 'https://your-finzep-host';
const apiKey = 'fzp_...';
const clientKeyId = 'ck_...';
const clientPrivateKeyPem = fs.readFileSync('client-private.pem', 'utf8');

async function main() {
  // 1) our public key (once; cache it)
  const pk = await (await fetch(base + '/API/PublicKey')).json();

  // 2) the plain request
  const payout = { APIRequestID: 'REQ-' + Date.now(), TransMode: 'IMPS', Amount: 100, AccountNo: '1234567890123', IFSC: 'HDFC0000123',
                   BeneName: 'Ramesh Kumar', BeneMobile: '9876543210', SenderName: 'Suresh Sharma', SenderMobile: '9999999999', SenderEmail: 'sender@example.com' };

  // 3) encrypt + sign, send
  const { envelope, body } = buildRequest({ clientKeyId, serverKeyId: pk.serverKeyId, path: '/API/Payout', json: JSON.stringify(payout),
                                            clientPrivateKeyPem, serverPublicKeyPem: pk.publicKeyPem });
  const res = await fetch(base + '/API/Payout', { method: 'POST', body,
    headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey, 'X-Encrypted': '1' } });
  const text = await res.text();

  // 4) verify + decrypt (plain JSON means an early error such as a wrong API key)
  const answer = res.headers.has('x-encrypted')
    ? JSON.parse(openResponse({ responseBody: text, requestEnvelope: envelope, clientPrivateKeyPem, serverPublicKeyPem: pk.publicKeyPem }))
    : JSON.parse(text);
  console.log(res.status, answer);   // { success, status, statusName, message, apiRequestID, transactionId, utr, errorCode, referenceId }
}
main();
// FzpClient.cs from this page, in your project
using System.Security.Cryptography;
using System.Text.Json;

var http = new HttpClient { BaseAddress = new Uri("https://your-finzep-host") };

// our public key (once; cache it)
var pk = JsonDocument.Parse(await http.GetStringAsync("/API/PublicKey")).RootElement;
using var serverPublic = RSA.Create();
serverPublic.ImportFromPem(pk.GetProperty("publicKeyPem").GetString());
using var clientPrivate = RSA.Create();
clientPrivate.ImportFromPem(File.ReadAllText("client-private.pem"));

var client = new FzpClient(http, apiKey: "fzp_...", clientKeyId: "ck_...", clientPrivate,
                           pk.GetProperty("serverKeyId").GetString()!, serverPublic);

var payout = JsonSerializer.Serialize(new {
    APIRequestID = "REQ-" + DateTime.UtcNow.Ticks, TransMode = "IMPS", Amount = 100.00, AccountNo = "1234567890123", IFSC = "HDFC0000123",
    BeneName = "Ramesh Kumar", BeneMobile = "9876543210", SenderName = "Suresh Sharma", SenderMobile = "9999999999", SenderEmail = "sender@example.com" });

var (status, answer) = await client.PostAsync("/API/Payout", payout);   // encrypts, signs, verifies and decrypts for you
Console.WriteLine($"{status} {answer}");
import json, time
from fzp_client import FzpClient
import requests

base = "https://your-finzep-host"
pk = requests.get(base + "/API/PublicKey").json()            # our public key (once; cache it)

client = FzpClient(base_url=base, api_key="fzp_...", client_key_id="ck_...",
                   client_private_pem=open("client-private.pem").read(),
                   server_key_id=pk["serverKeyId"], server_public_pem=pk["publicKeyPem"])

payout = {"APIRequestID": "REQ-%d" % int(time.time()), "TransMode": "IMPS", "Amount": 100.00, "AccountNo": "1234567890123",
          "IFSC": "HDFC0000123", "BeneName": "Ramesh Kumar", "BeneMobile": "9876543210",
          "SenderName": "Suresh Sharma", "SenderMobile": "9999999999", "SenderEmail": "sender@example.com"}

status, answer = client.post("/API/Payout", payout)         # encrypts, signs, verifies and decrypts for you
print(status, answer)   # {'success': True, 'status': 2, 'statusName': 'Success', ..., 'transactionId': 'S2610...', 'errorCode': 0}
// FzpClient.java from this page, in your project
import java.net.URI;
import java.net.http.*;

public class Demo {
    public static void main(String[] a) throws Exception {
        String base = "https://your-finzep-host";
        String pk = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(base + "/API/PublicKey")).build(),
                HttpResponse.BodyHandlers.ofString()).body();                    // our public key (once; cache it)
        String serverKeyId = FzpClient.field(pk, "serverKeyId");
        String serverPem = FzpClient.field(pk, "publicKeyPem").replace("\\n", "\n");

        FzpClient c = new FzpClient(base, "fzp_...", "ck_...",
                java.nio.file.Files.readString(java.nio.file.Path.of("client-private.pem")), serverKeyId, serverPem);

        String payout = "{\"APIRequestID\":\"REQ-" + System.currentTimeMillis() + "\",\"TransMode\":\"IMPS\",\"Amount\":100.00,"
                + "\"AccountNo\":\"1234567890123\",\"IFSC\":\"HDFC0000123\",\"BeneName\":\"Ramesh Kumar\",\"BeneMobile\":\"9876543210\","
                + "\"SenderName\":\"Suresh Sharma\",\"SenderMobile\":\"9999999999\",\"SenderEmail\":\"sender@example.com\"}";
        System.out.println(c.post("/API/Payout", payout));                          // encrypts, signs, verifies and decrypts for you
    }
}

Webhook receivers (a small server that verifies and decrypts our calls): webhook-receiver.js, webhook_receiver.py.txt, WebhookReceiver.java.txt - see Webhooks.

PHP, Go and others: implement the specification with your language's standard crypto library (RSA-OAEP-SHA256, RSA-PSS-SHA256 with salt length 32, AES-256-GCM) - the four samples above are the reference.

Postman

Download Finzep-Payout-API.postman_collection.json and Finzep-Payout.postman_environment.json, and import both.

  1. Select the Finzep Payout environment and fill baseUrl, apiKey, clientKeyId and clientPrivateKey (paste the whole PEM of your private key).
  2. Run 0. Setup → Get server public key. It stores serverKeyId and serverPublicKey.
  3. Run 1. Payout → Payout - bank transfer (IMPS). Edit the plain JSON in the Body as you like - a pre-request script encrypts and signs it just before sending, and a tests script verifies and decrypts the answer. See the Visualize tab (or the console) for the plain answer.
  4. Run Payout status (last request); {{lastApiRequestId}} is filled by the previous payout call.
  5. Folder 2. Learn the errors shows what a plain request (1012), a missing key and a wrong key return.
The scripts use the node-forge library through pm.require('npm:node-forge@1.3.1'), which needs a Postman version with npm package support in scripts. The script logic was tested against our server outside Postman (in Node.js with a Postman-style pm object). If your Postman version cannot load npm packages, build the envelope with the Node.js CLI instead:
node fzp-client.js build-request --client-key-id ck_... --server-key-id sk_... --path /API/Payout \
     --client-private client-private.pem --server-public server-public.pem --json '{"APIRequestID":"REQ-1", ...}'
and paste the printed envelope as the raw body of a request with the headers X-Api-Key and X-Encrypted: 1; save the response to a file and open it with open-response.

Troubleshooting

You seeCheck
401, errorCode 211, "Invalid or missing API key"Header name is exactly X-Api-Key; the key is the full 68-character value; it was not regenerated or revoked.
403, 211, "IP address not allowed"Your server's outgoing IP is not in API Access → IP whitelist (behind NAT / a proxy it may differ from what you expect).
400, 1012You sent plain JSON, or forgot the X-Encrypted: 1 header.
1013clientKeyId belongs to another account, was revoked or has expired; or serverKeyId is an old key (>30 days after rotation). Fetch /API/PublicKey again.
1009 signature failedThe signed text must match the spec byte for byte: the path you signed must be the path you called; join with \n only (not \r\n); Base64 strings exactly as sent; RSA-PSS (not PKCS#1 v1.5) with salt length 32; sign with the private key that matches the registered public key.
1008 decryption failedEncrypt the AES key with OAEP + SHA-256 and MGF1-SHA-256 (Java's default MGF1 is SHA-1 - set it explicitly); AES key 32 bytes, IV 12 bytes, tag (16 bytes) appended; the additional data is timestamp + "\n" + nonce; you used our public key.
1010 timestampUse UTC and the format 2026-10-09T12:00:00Z (no milliseconds); fix the server clock.
1011 nonce reusedA retry must build a new envelope (new nonce, IV, timestamp, key), not resend the old one.
Your client cannot verify our answerUse the nonce of your request in the third line of the response's signed text; verify with the public key identified by serverKeyId (the one you used); do not trust an answer that fails verification.
150 on a retryThat is correct: the first call was recorded. Call /API/Payout/Status with the same APIRequestID.
No webhook arrivesThe webhook is saved and Enabled; the URL is https on a public address and answers within 10 seconds; you have a registered public key (we cannot encrypt without one); your firewall lets our servers in. In the portal list: Pending = we will retry (see the error shown), Failed (gave up) = fix the cause and press Resend. Remember a webhook is only sent for changes that happen after the original request.
Webhook signature does not verifyUse the X-Webhook-Id header as line 3 of the signed text, WEBHOOK as line 2, the raw body exactly as received, and our public key (the serverKeyId in the envelope).
143Our side failed unexpectedly. Check the payout's status; give support the referenceId.

Security checklist