{
  "info": {
    "name": "Finzep Payout API",
    "description": "Signed + encrypted payout API (spec FZP1). Setup: 1) import this collection and the environment; 2) fill apiKey, clientKeyId and clientPrivateKey (the private half of the public key you registered in the portal, PEM); 3) run '0. Get server public key'; 4) run the payout requests. Full guide: {{baseUrl}}/docs/payout-api-guide.html. Needs a Postman version with npm package support in scripts (pm.require('npm:...')).",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "// ============================================================================================================",
          "// Finzep payout API - FZP1 request encryption  (collection-level PRE-REQUEST script)",
          "//",
          "// Every request that has the header  X-Encrypted: 1  is signed with YOUR private key and encrypted for OUR public key.",
          "// Write the NORMAL plain JSON in the request Body - this script turns it into the envelope just before it is sent.",
          "// Needs the collection variables: clientKeyId, clientPrivateKey, serverKeyId, serverPublicKey (run \"0. Get server public key\" first).",
          "// Uses node-forge through Postman's npm package support: pm.require('npm:node-forge@1.3.1').",
          "// ============================================================================================================",
          "if (pm.request.headers.has('X-Encrypted')) {",
          "    const forge = pm.require('npm:node-forge@1.3.1');",
          "    const fix = (pem) => String(pem || '').replace(/\\\\n/g, '\\n').trim();   // tolerate a PEM pasted with literal \\n",
          "",
          "    const clientKeyId = pm.variables.get('clientKeyId');",
          "    const serverKeyId = pm.variables.get('serverKeyId');",
          "    const clientPrivatePem = fix(pm.variables.get('clientPrivateKey'));",
          "    const serverPublicPem = fix(pm.variables.get('serverPublicKey'));",
          "    if (!clientKeyId || !clientPrivatePem || !serverKeyId || !serverPublicPem) {",
          "        throw new Error('Set clientKeyId and clientPrivateKey, and run \"0. Get server public key\" first (it fills serverKeyId and serverPublicKey).');",
          "    }",
          "",
          "    // the plain JSON the API expects (Postman variables such as {{$timestamp}} are resolved here)",
          "    const plain = pm.variables.replaceIn(pm.request.body.raw);",
          "    try { JSON.parse(plain); } catch (e) { throw new Error('The request body is not valid JSON: ' + e.message); }",
          "    const path = '/' + pm.request.url.getPath().replace(/^\\/+/, '');   // e.g. /API/Payout - part of the signature",
          "",
          "    const b64 = (bytes) => forge.util.encode64(bytes);",
          "    const aesKey = forge.random.getBytesSync(32);",
          "    const iv = forge.random.getBytesSync(12);",
          "    const env = {",
          "        clientKeyId: clientKeyId,",
          "        serverKeyId: serverKeyId,",
          "        timestamp: new Date().toISOString().replace(/\\.\\d{3}Z$/, 'Z'),",
          "        nonce: b64(forge.random.getBytesSync(16)),",
          "        iv: b64(iv)",
          "    };",
          "",
          "    // encryptedKey: the one-time AES key, RSA-OAEP (SHA-256) for OUR public key",
          "    const serverPublic = forge.pki.publicKeyFromPem(serverPublicPem);",
          "    env.encryptedKey = b64(serverPublic.encrypt(aesKey, 'RSA-OAEP', { md: forge.md.sha256.create(), mgf1: { md: forge.md.sha256.create() } }));",
          "",
          "    // payload: AES-256-GCM of the JSON, additional data = timestamp + \"\\n\" + nonce, ciphertext followed by the 16-byte tag",
          "    const cipher = forge.cipher.createCipher('AES-GCM', aesKey);",
          "    cipher.start({ iv: iv, additionalData: env.timestamp + '\\n' + env.nonce, tagLength: 128 });",
          "    cipher.update(forge.util.createBuffer(forge.util.encodeUtf8(plain)));",
          "    cipher.finish();",
          "    env.payload = b64(cipher.output.getBytes() + cipher.mode.tag.getBytes());",
          "",
          "    // signature: RSA-PSS (SHA-256, salt 32) with YOUR private key over the signed text",
          "    const signedText = ['FZP1', 'REQUEST', path, env.clientKeyId, env.serverKeyId, env.timestamp, env.nonce, env.encryptedKey, env.iv, env.payload].join('\\n');",
          "    const md = forge.md.sha256.create();",
          "    md.update(signedText, 'utf8');",
          "    const pss = forge.pss.create({ md: forge.md.sha256.create(), mgf: forge.mgf.mgf1.create(forge.md.sha256.create()), saltLength: 32 });",
          "    env.signature = b64(forge.pki.privateKeyFromPem(clientPrivatePem).sign(md, pss));",
          "",
          "    // keep what the test script needs, show the plain request in the console, send the envelope",
          "    pm.variables.set('fzpRequestNonce', env.nonce);",
          "    console.log('Plain request (before encryption):', plain);",
          "    pm.request.body.update({ mode: 'raw', raw: JSON.stringify(env), options: { raw: { language: 'json' } } });",
          "}",
          ""
        ]
      }
    },
    {
      "listen": "test",
      "script": {
        "type": "text/javascript",
        "exec": [
          "// ============================================================================================================",
          "// Finzep payout API - FZP1 response decryption  (collection-level TESTS script)",
          "//",
          "// For an encrypted call: verifies OUR signature, decrypts the answer with YOUR private key, runs checks and shows the plain JSON",
          "// in the \"Visualize\" tab (and in the console). Errors found before the message could be trusted (bad API key, IP, rate limit,",
          "// bad envelope ...) are plain JSON - they are shown as they are.",
          "// ============================================================================================================",
          "if (pm.request.headers.has('X-Encrypted')) {",
          "    const forge = pm.require('npm:node-forge@1.3.1');",
          "    const fix = (pem) => String(pem || '').replace(/\\\\n/g, '\\n').trim();",
          "",
          "    if (!pm.response.headers.has('X-Encrypted')) {",
          "        // not encrypted: a plain error (see the guide, section \"Errors\")",
          "        let err = {};",
          "        try { err = pm.response.json(); } catch (e) { /* not JSON */ }",
          "        pm.test('Plain error answer: HTTP ' + pm.response.code + (err.errorCode ? ', errorCode ' + err.errorCode : ''), () => pm.expect(pm.response.code).to.be.oneOf([400, 401, 403, 413, 429, 503]));",
          "        console.log('Plain (unencrypted) answer:', pm.response.text());",
          "        pm.visualizer.set('<pre style=\"font-size:13px\">{{json}}</pre>', { json: JSON.stringify(err, null, 2) });",
          "    } else {",
          "        const r = pm.response.json();",
          "        const clientPrivate = forge.pki.privateKeyFromPem(fix(pm.variables.get('clientPrivateKey')));",
          "        const serverPublic = forge.pki.publicKeyFromPem(fix(pm.variables.get('serverPublicKey')));",
          "        const pss = forge.pss.create({ md: forge.md.sha256.create(), mgf: forge.mgf.mgf1.create(forge.md.sha256.create()), saltLength: 32 });",
          "",
          "        // 1) is it really from us? signed text = FZP1 / RESPONSE / the nonce of YOUR request / ... / payload",
          "        const signedText = ['FZP1', 'RESPONSE', pm.variables.get('fzpRequestNonce'), r.clientKeyId, r.serverKeyId, r.timestamp, r.nonce, r.encryptedKey, r.iv, r.payload].join('\\n');",
          "        const md = forge.md.sha256.create();",
          "        md.update(signedText, 'utf8');",
          "        let signatureOk = false;",
          "        try { signatureOk = serverPublic.verify(md.digest().getBytes(), forge.util.decode64(r.signature), pss); } catch (e) { signatureOk = false; }",
          "        pm.test('Server signature verifies', () => pm.expect(signatureOk, 'the answer is not signed by our server key - do not trust it').to.be.true);",
          "",
          "        // 2) decrypt: RSA-OAEP unwraps the AES key, AES-256-GCM opens the payload (last 16 bytes = tag)",
          "        let plain = null;",
          "        try {",
          "            const aesKey = clientPrivate.decrypt(forge.util.decode64(r.encryptedKey), 'RSA-OAEP', { md: forge.md.sha256.create(), mgf1: { md: forge.md.sha256.create() } });",
          "            const payload = forge.util.decode64(r.payload);",
          "            const decipher = forge.cipher.createDecipher('AES-GCM', aesKey);",
          "            decipher.start({ iv: forge.util.decode64(r.iv), additionalData: r.timestamp + '\\n' + r.nonce, tagLength: 128, tag: forge.util.createBuffer(payload.slice(payload.length - 16)) });",
          "            decipher.update(forge.util.createBuffer(payload.slice(0, payload.length - 16)));",
          "            if (decipher.finish()) { plain = forge.util.decodeUtf8(decipher.output.getBytes()); }",
          "        } catch (e) { plain = null; }",
          "        pm.test('Answer decrypts', () => pm.expect(plain, 'could not decrypt - is clientPrivateKey the private half of the registered public key?').to.not.be.null);",
          "",
          "        if (plain !== null) {",
          "            const answer = JSON.parse(plain);",
          "            pm.variables.set('fzpDecryptedResponse', plain);",
          "            pm.test('Decrypted answer: ' + (answer.statusName || '') + (answer.message ? ' - ' + answer.message : '') + ' (errorCode ' + answer.errorCode + ')', () => pm.expect(answer).to.have.property('errorCode'));",
          "            console.log('Decrypted answer:', JSON.stringify(answer, null, 2));",
          "            pm.visualizer.set('<pre style=\"font-size:13px\">{{json}}</pre>', { json: JSON.stringify(answer, null, 2) });",
          "            // handy for the next request (status check uses the same APIRequestID)",
          "            if (answer.apiRequestID) { pm.collectionVariables.set('lastApiRequestId', answer.apiRequestID); }",
          "            if (answer.transactionId) { pm.collectionVariables.set('lastTransactionId', answer.transactionId); }",
          "        }",
          "    }",
          "}",
          ""
        ]
      }
    }
  ],
  "variable": [
    {
      "key": "baseUrl",
      "value": "https://localhost:7220"
    },
    {
      "key": "apiKey",
      "value": ""
    },
    {
      "key": "clientKeyId",
      "value": ""
    },
    {
      "key": "clientPrivateKey",
      "value": ""
    },
    {
      "key": "serverKeyId",
      "value": ""
    },
    {
      "key": "serverPublicKey",
      "value": ""
    },
    {
      "key": "lastApiRequestId",
      "value": "REQ-0001"
    },
    {
      "key": "lastTransactionId",
      "value": ""
    }
  ],
  "item": [
    {
      "name": "0. Setup",
      "item": [
        {
          "name": "Get server public key",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/API/PublicKey",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "API",
                "PublicKey"
              ]
            },
            "description": "Public endpoint (no key needed). Stores serverKeyId and serverPublicKey in the collection variables for the other requests. Check the fingerprint against the one the admin gave you."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const r = pm.response.json();",
                  "pm.test('Server public key received', () => { pm.expect(pm.response.code).to.eql(200); pm.expect(r.serverKeyId).to.match(/^sk_/); });",
                  "pm.collectionVariables.set('serverKeyId', r.serverKeyId);",
                  "pm.collectionVariables.set('serverPublicKey', r.publicKeyPem);",
                  "console.log('serverKeyId', r.serverKeyId, 'fingerprint', r.fingerprint);"
                ]
              }
            }
          ],
          "response": []
        }
      ]
    },
    {
      "name": "1. Payout",
      "item": [
        {
          "name": "Payout - bank transfer (IMPS)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "X-Api-Key",
                "value": "{{apiKey}}",
                "type": "text"
              },
              {
                "key": "X-Encrypted",
                "value": "1",
                "type": "text"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"APIRequestID\": \"REQ-{{$timestamp}}\",\n  \"TransMode\": \"IMPS\",\n  \"Amount\": 100.00,\n  \"AccountNo\": \"1234567890123\",\n  \"IFSC\": \"HDFC0000123\",\n  \"BeneName\": \"Ramesh Kumar\",\n  \"BeneMobile\": \"9876543210\",\n  \"SenderName\": \"Suresh Sharma\",\n  \"SenderMobile\": \"9999999999\",\n  \"SenderEmail\": \"sender@example.com\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/API/Payout",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "API",
                "Payout"
              ]
            },
            "description": "Write the NORMAL plain JSON in the body. The collection's pre-request script signs it with your private key and encrypts it for our public key (header X-Encrypted: 1 turns this on), and the Tests script verifies + decrypts the answer - see the Visualize tab and the console. TransMode: IMPS / NEFT / RTGS need AccountNo (5-50 chars) and an 11-character IFSC. APIRequestID must be unique per payout (a repeat is refused with code 150)."
          },
          "response": []
        },
        {
          "name": "Payout - UPI",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "X-Api-Key",
                "value": "{{apiKey}}",
                "type": "text"
              },
              {
                "key": "X-Encrypted",
                "value": "1",
                "type": "text"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"APIRequestID\": \"REQ-{{$timestamp}}\",\n  \"TransMode\": \"UPI\",\n  \"Amount\": 50.00,\n  \"AccountNo\": \"ramesh@okhdfc\",\n  \"IFSC\": null,\n  \"BeneName\": \"Ramesh Kumar\",\n  \"BeneMobile\": \"9876543210\",\n  \"SenderName\": \"Suresh Sharma\",\n  \"SenderMobile\": \"9999999999\",\n  \"SenderEmail\": \"sender@example.com\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/API/Payout",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "API",
                "Payout"
              ]
            },
            "description": "Write the NORMAL plain JSON in the body. The collection's pre-request script signs it with your private key and encrypts it for our public key (header X-Encrypted: 1 turns this on), and the Tests script verifies + decrypts the answer - see the Visualize tab and the console. TransMode UPI: AccountNo is the VPA (must contain @), IFSC is not needed."
          },
          "response": []
        },
        {
          "name": "Payout status (last request)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "X-Api-Key",
                "value": "{{apiKey}}",
                "type": "text"
              },
              {
                "key": "X-Encrypted",
                "value": "1",
                "type": "text"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{ \"APIRequestID\": \"{{lastApiRequestId}}\" }",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/API/Payout/Status",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "API",
                "Payout",
                "Status"
              ]
            },
            "description": "Write the NORMAL plain JSON in the body. The collection's pre-request script signs it with your private key and encrypts it for our public key (header X-Encrypted: 1 turns this on), and the Tests script verifies + decrypts the answer - see the Visualize tab and the console. Asks for the current status of one of YOUR payouts by its APIRequestID. {{lastApiRequestId}} is filled by the previous payout call; type any APIRequestID to check another."
          },
          "response": []
        }
      ]
    },
    {
      "name": "2. Learn the errors (these calls are expected to fail)",
      "item": [
        {
          "name": "Plain (unencrypted) request - refused with 1012",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "X-Api-Key",
                "value": "{{apiKey}}",
                "type": "text"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{ \"APIRequestID\": \"REQ-0001\" }",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/API/Payout/Status",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "API",
                "Payout",
                "Status"
              ]
            },
            "description": "No X-Encrypted header and no envelope: the API refuses it with HTTP 400, errorCode 1012. Encryption is mandatory."
          },
          "response": [],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('Plain request is refused (HTTP 400, errorCode 1012)', () => { pm.expect(pm.response.code).to.eql(400); pm.expect(pm.response.json().errorCode).to.eql(1012); });"
                ]
              }
            }
          ]
        },
        {
          "name": "Missing API key - 401 / 211",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "X-Encrypted",
                "value": "1",
                "type": "text"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{ \"APIRequestID\": \"REQ-0001\" }",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/API/Payout/Status",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "API",
                "Payout",
                "Status"
              ]
            },
            "description": "Without X-Api-Key the call is refused before anything is decrypted: HTTP 401, errorCode 211 (plain JSON)."
          },
          "response": []
        },
        {
          "name": "Wrong API key - 401 / 211",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "X-Api-Key",
                "value": "fzp_0000000000000000000000000000000000000000000000000000000000000000",
                "type": "text"
              },
              {
                "key": "X-Encrypted",
                "value": "1",
                "type": "text"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{ \"APIRequestID\": \"REQ-0001\" }",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{baseUrl}}/API/Payout/Status",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "API",
                "Payout",
                "Status"
              ]
            },
            "description": "A wrong key: HTTP 401, errorCode 211. Ten wrong keys from one IP within 10 minutes block that IP for a while (HTTP 429, errorCode 1007)."
          },
          "response": []
        }
      ]
    }
  ]
}