Overview

Techzopp API

Create payment collection orders (Payin) and send money to bank accounts or UPI IDs (Payout) directly from your own backend, without a login session — authenticated with a single API key.

Base URL

https://api.techzopp.com/api/v1

Every endpoint on this page is relative to this base URL. There is one API — see Sandbox & Live for how test vs. real transactions are controlled.

What you can do

CapabilityEndpoint
Collect a payment from a customerPOST /payin
Check whether a payin succeededGET /payin/:orderId/status
Send money to a bank account or UPI IDPOST /payout
Check a payout's statusGET /payout/:id/status

Getting your API keys

Sign in to your merchant dashboard → DeveloperAPI security → Generate. You'll find two separate keys there — a Live key and a Test key — see Sandbox & Live for how they differ. Keep both secret; whichever one you send authenticates every request as you.

Get started

Authentication

Every request is authenticated with a single header carrying your API key. There's no OAuth flow — just the header below.

Header

HeaderValue
x-api-keyYour Live key or Test key (see below)
curl https://api.techzopp.com/api/v1/payin/status \
  -H "x-api-key: YOUR_API_KEY"

Two separate keys

Unlike a single key, this platform gives you a Live key and a Test key — grab both from Developer → API security in your dashboard.

KeyPrefixWhat it can reach
Live keypk_live_...Only gateway accounts your admin has marked Live mode — moves real money
Test keypk_test_...Only gateway accounts marked Test mode — no real money moves

Using your Test key when no gateway is in Test mode returns a 503 telling you to ask your admin to enable one — it will never silently fall through to a live gateway.

Key storage, expiry & scope

Each key is shown to you once, right when you generate it — after that, the dashboard only shows whether one is configured, never the value itself (we store a one-way hash, not the plaintext, so it can't be recovered even from our own database). Save it somewhere safe immediately.

Keys expire 1 year after generation. An expired key returns 401 — regenerate it from Developer → API security before then to avoid an interruption; regenerating immediately invalidates the old value.

Scope: when generating or regenerating a key, choose whether it can call payin, payout, or both — pick this in Developer → API security, next to the Generate/Regenerate button for each key. A request using a scope the key wasn't granted gets 403. Keys generated before this existed (or generated without touching the scope checkboxes) default to both — this is additive, not a breaking change. If you hand a key to a system that should only ever collect payments, generate it with just the payin scope so a compromise of that system can't also trigger payouts.

Errors

StatusMeaning
401Missing, invalid, or expired x-api-key
403Account deactivated, request IP isn't on your allowlist, or the key doesn't have the scope this endpoint requires

IP allowlisting (optional)

In Developer → API security you can restrict your key to specific server IPs. If you've added any IPs there, requests from anywhere else are rejected with 403 — leave it empty to allow any IP. This checks the request's actual source IP as seen by our server (not a client-supplied header), so it can't be bypassed by spoofing a header value.

Get started

Sandbox & Live

One base URL for both — which one you're in depends entirely on which API key you send.

Your Test key can never touch real money. It only ever reaches gateway accounts your admin has explicitly marked Test mode in Gateways settings. Your Live key only reaches gateways marked Live. There's no way to cross the streams by accident.

How it behaves

Sandbox (use your Test key)Live (use your Live key)
Base URLhttps://api.techzopp.com/api/v1 (same)
Headerx-api-key: pk_test_...x-api-key: pk_live_...
Money movementNo real money movesReal money moves
If no matching gateway exists503 — ask your admin to mark a gateway Test mode503 — no active gateway available
Response formatIdentical shape either way

Get both keys from Developer → API security in your dashboard.

Test card / UPI details

Whichever underlying gateway (Razorpay/PayU/etc) is marked Test mode determines what test credentials you use — check that provider's own published test card numbers and test UPI IDs (e.g. success@razorpay).

Payin
POST /payin

Create a Payin

Starts a payment collection order. The response gives you a checkout payload to redirect your customer to (or a UPI intent link) — once they pay, your webhook fires and your wallet is credited.

All amounts in this API are in paise, including the request body. {"amount": 50000} means ₹500. Every endpoint - Create, Check Status, webhooks, Payout - uses the same field name amount and the same unit, paise, everywhere. There is no rupees/paise split anymore.

Request body

FieldTypeRequiredDescription
amountintegerRequiredAmount in paise - e.g. 50000 for ₹500. Must be a whole number; fractional paise is rejected.
referenceIdstringRequiredYour own unique idempotency key. A retried request with the same value returns the original order (including its checkout payload) instead of creating a second one - see the idempotent-replay response below.

Example request

curl -X POST https://api.techzopp.com/api/v1/payin \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 50000,
    "referenceId": "order-4471"
  }'
const res = await fetch("https://api.techzopp.com/api/v1/payin", {
  method: "POST",
  headers: {
    "x-api-key": "YOUR_API_KEY",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ amount: 50000, referenceId: "order-4471" })
});
const data = await res.json();

Success response

201 Created
{
  "success": true,
  "data": {
    "gateway": "razorpay",
    "testMode": false,
    "orderId": "api_1721234567890_a1b2c3",
    "checkout": {
      "orderId": "order_ABC123",
      "amount": 50000,
      "currency": "INR",
      "keyId": "rzp_live_xxxxx"
    },
    "amount": 50000,
    "currency": "INR"
  }
}

checkout's shape depends on the active gateway — for Razorpay it's data for Razorpay Checkout.js; for PayU it's either a form-post payload ({actionUrl, fields}) or, if UPI Intent mode is on, {mode: "upi_intent", intentUrl} — a upi://pay?... link to redirect the browser to. (The top-level amount/currency describe your order; checkout.amount/checkout.currency are the gateway's own copy for its checkout widget - they're always equal, just two places that need it.)

200 OK (idempotent replay)
{
  "success": true,
  "message": "Already processed (idempotent replay)",
  "data": {
    "gateway": "razorpay",
    "testMode": false,
    "orderId": "api_1721234567890_a1b2c3",
    "checkout": {
      "orderId": "order_ABC123",
      "amount": 50000,
      "currency": "INR",
      "keyId": "rzp_live_xxxxx"
    },
    "status": "pending",
    "amount": 50000,
    "currency": "INR"
  }
}

Returned when you reuse a referenceId from an earlier call - note this includes the same checkout payload as the original 201 response, not just an acknowledgement. That matters specifically for the case that motivates referenceId existing at all: your original request succeeded on our end but its response never reached you (network drop, timeout, your process crashed mid-request). Retrying with the same referenceId is how you recover the checkout link in that scenario, not just how you avoid a duplicate order.

200 OK (still processing)
{
  "success": true,
  "message": "A request with this referenceId is already being processed - check back in a moment.",
  "data": { "gateway": "", "orderId": null, "checkout": null, "status": "pending", "amount": 50000, "currency": "INR" }
}

Returned only in the rare case where two requests with the same brand-new referenceId arrive close enough together that this one loses the race to claim it - the other request is still in the middle of calling the gateway. orderId/checkout are null because they don't exist yet. Wait briefly and retry with the same referenceId - you'll then get the normal idempotent-replay response above once the winning request finishes. This request never calls the gateway itself, so it's impossible for two gateway-side orders to be created from one referenceId.

Error responses

400 Bad Request
{ "success": false, "message": "A valid amount in paise (integer) is required" }
{ "success": false, "message": "referenceId is required (idempotency key)" }
403 Forbidden
{ "success": false, "message": "Payin is disabled for this account" }
503 Service Unavailable
{ "success": false, "message": "No active payment gateway can process this amount right now" }
Payin
GET /payin/:orderId/status

Check Payin Status

Poll this if you'd rather not rely solely on webhooks — pass the orderId you got back from Create a Payin.

Path parameter

FieldDescription
orderIdThe orderId returned by POST /payin

Example request

curl https://api.techzopp.com/api/v1/payin/api_1721234567890_a1b2c3/status \
  -H "x-api-key: YOUR_API_KEY"

Success response

200 OK
{
  "success": true,
  "data": {
    "orderId": "api_1721234567890_a1b2c3",
    "gateway": "razorpay",
    "amount": 50000,
    "currency": "INR",
    "status": "success",
    "merchantReferenceId": "order-4471",
    "checkout": { "orderId": "order_ABC123", "amount": 50000, "currency": "INR", "keyId": "rzp_live_xxxxx" }
  }
}

checkout is the same payload from the original create call - a way to recover it if you lost the create response and don't have (or don't want to reuse) the referenceId.

status valueMeaning
pendingCustomer hasn't completed payment yet
successPayment completed, wallet credited
failedPayment failed or was abandoned

amount is in paise (₹500 → 50000) - same unit as the request body now, no conversion needed either direction.

Error response

404 Not Found
{ "success": false, "message": "Order not found" }
Payout
POST /payout

Create a Payout

Sends money to a bank account (IMPS/NEFT/RTGS) or a UPI ID. The amount is held from your wallet immediately; the platform admin confirms the actual bank transfer with a UTR.

referenceId is your idempotency key. Retrying the same referenceId returns the existing payout instead of creating a duplicate — safe to retry on timeout.

Request body

FieldTypeRequiredDescription
amountintegerRequiredAmount in paise - e.g. 100000 for ₹1,000. Same unit as every other amount field in this API.
referenceIdstringRequiredYour own unique idempotency key
beneficiaryNamestringRequiredName on the receiving account
beneficiaryAccountNumberstringOptional*9-18 digit bank account number (for IMPS/NEFT/RTGS) - requires beneficiaryIfsc in the same request, it's not accepted alone
beneficiaryIfscstringOptional*Must match the standard IFSC format: 4 letters, then 0, then 6 letters/digits (e.g. SBIN0001234) - required together with beneficiaryAccountNumber
beneficiaryUpistringOptional*UPI ID in name@bank form, if paying out via UPI instead of bank account
requestedModestringOptionalIMPS / NEFT / RTGS / UPI - defaults to IMPS. Display-only, not a routing control: every payout is a manually-confirmed bank/UPI transfer on the admin side (see below), so this is stored and shown back to you on the payout record but does not select or influence which rail your admin actually uses to send the money. Named requestedMode rather than mode specifically so the name itself doesn't imply control it doesn't have. Don't build logic that assumes the real transfer happened via the mode you requested.

* Provide either bank details (beneficiaryAccountNumber + beneficiaryIfsc, both required together) or beneficiaryUpi - not both, not neither. Malformed IFSC, an account number outside 9-18 digits, or a UPI ID without an @ all return 400 before anything is held from your wallet.

Example request

curl -X POST https://api.techzopp.com/api/v1/payout \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 100000,
    "referenceId": "order-4471-payout",
    "beneficiaryName": "Rahul Singh",
    "beneficiaryAccountNumber": "1234567890",
    "beneficiaryIfsc": "SBIN0001234",
    "requestedMode": "IMPS"
  }'

Success response

201 Created
{
  "success": true,
  "message": "Payout created",
  "data": {
    "_id": "66a1f...e921",
    "amount": 100000,
    "currency": "INR",
    "status": "pending",
    "beneficiaryName": "Rahul Singh",
    "merchantReferenceId": "order-4471-payout",
    "requestedMode": "IMPS",
    "merchantCharge": { "commission": 1500, "gst": 270, "totalCharge": 1770 }
  }
}

Note the field name changes: you send referenceId, it comes back as merchantReferenceId; you send requestedMode, it comes back the same name (unlike the internal admin panel, which still calls this field "mode" - only this public API uses the clearer name).

200 OK (idempotent replay)
{ "success": true, "message": "Already processed (idempotent replay)", "data": { "...": "same payout as before" } }

You get 200 when we find an existing payout with that referenceId before creating a new one - the normal case for a retried request. You get 409 below instead only in the rare case where two requests with the same brand-new referenceId arrive close enough together that both pass that check before either finishes being recorded - a genuine race, not a normal retry. Either way, nothing is ever double-charged: exactly one payout row is ever created per referenceId.

Error responses

400 Bad Request
{ "success": false, "message": "Insufficient available balance. ₹500.00 is on hold." }
409 Conflict
{ "success": false, "message": "A payout with this referenceId already exists" }
Payout
GET /payout/:id/status

Check Payout Status

Pass the _id you got back from Create a Payout.

Example request

curl https://api.techzopp.com/api/v1/payout/66a1f...e921/status \
  -H "x-api-key: YOUR_API_KEY"

Success response

200 OK
{
  "success": true,
  "data": {
    "_id": "66a1f...e921",
    "amount": 100000,
    "currency": "INR",
    "status": "success",
    "utrNumber": "SBIN0012345678",
    "beneficiaryName": "Rahul Singh",
    "merchantReferenceId": "order-4471-payout"
  }
}
status valueMeaning
pendingHeld from your wallet, awaiting admin confirmation of the bank/UPI transfer
successAdmin confirmed the transfer with a UTR — see utrNumber. This means the admin recorded the transfer as sent, not an independent confirmation from the bank that funds landed.
failedRejected by the admin — amount refunded to your wallet

Watch the exact values: this is success / failed, not approved / rejected — matching on the wrong string is a common integration bug.

Error response

404 Not Found
{ "success": false, "message": "Payout not found" }
Events

Webhooks

Configure one or more webhook URLs in Developer → API security to get pushed a signed event the moment a payin or payout succeeds, instead of polling.

Events we send

EventFires when
payin.successA payin completes and your wallet is credited
payout.successAn admin confirms a payout with a UTR and your wallet debit is finalized
There is currently no webhook for a failed or rejected payin/payout — only the two success events above exist. For failure/rejection, you must poll Check Payin Status / Check Payout Status yourself (e.g. after a reasonable timeout with no success webhook). Don't build logic that assumes a webhook will eventually tell you something failed.

Request we send you

HeaderValue
X-Webhook-EventThe event name, e.g. payin.success
X-Webhook-SignatureHMAC-SHA256 of the raw JSON body, hex-encoded, signed with your webhook secret

Example payload — payin.success

{
  "eventId": "evt_9f2c1a4b7e6d3f0158a2c9b4e7d6f301",
  "event": "payin.success",
  "data": {
    "transactionId": "66a1e...c110",
    "orderId": "api_1721234567890_a1b2c3",
    "type": "payin",
    "amount": 50000,
    "gateway": "razorpay",
    "gatewayRef": "pay_ABC123",
    "merchantNetAmount": 48800,
    "status": "success"
  },
  "sentAt": "2026-07-20T09:15:32.000Z"
}

orderId is the same value you got back from POST /payin — match on this field to correlate the webhook to your original request. (transactionId is our internal record ID, and gatewayRef is the underlying gateway's own payment ID — neither of those is something you'll have seen before this webhook arrives.)

eventId uniquely and permanently identifies this delivery - unlike sentAt, it's identical across every retry attempt of the same delivery (see Retry schedule below). Use it as your dedup/audit key: store it alongside whatever you did in response, and if you ever see it again, you already handled it - skip reprocessing rather than re-running side effects. This is a stronger guarantee than deduping on orderId/transactionId, since those identify the underlying payin/payout, not the specific webhook delivery.

Verifying the signature (Node.js)

const crypto = require("crypto");

function isValid(rawBody, signatureHeader, webhookSecret) {
  const expected = crypto.createHmac("sha256", webhookSecret)
    .update(rawBody)
    .digest("hex");
  const expectedBuf = Buffer.from(expected, "hex");
  const receivedBuf = Buffer.from(signatureHeader || "", "hex");
  // Lengths must match before timingSafeEqual - it throws on mismatched
  // buffer lengths rather than just returning false.
  if (expectedBuf.length !== receivedBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}

Compute the HMAC over the exact raw request body (before any JSON parsing/reformatting) — re-serializing first is a common cause of signature mismatches. Use crypto.timingSafeEqual rather than === to compare — a plain string/byte comparison exits early on the first mismatched byte, which leaks (via response timing) how many leading bytes an attacker's guess got right, letting a signature be forged one byte at a time.

Replay protection

A signature alone doesn't stop someone who has captured one genuine webhook delivery from resending that exact same request later — it will still verify, since nothing about it has changed. sentAt is included inside the signed body specifically so you can reject stale replays: check that sentAt is within a few minutes of your server's current time, and reject (or at least flag) anything older. Also treat the combination of orderId/transactionId and status as safe to process more than once (i.e. make your handler idempotent) — a slow network can cause a delivery to look like it failed on our end and be indistinguishable from a genuine duplicate.

Delivery behaviour

  • Sent to every URL you've added, in parallel.
  • Your endpoint should respond with any 2xx status quickly; we don't wait on your processing to finish. Anything else (including a timeout) counts as a failed delivery and queues a retry.

Retry schedule

A failed delivery (non-2xx response, or your endpoint not responding) is retried automatically on a backoff schedule, up to 7 attempts total before we give up on that specific delivery:

AttemptSent
1Immediately
2+1 minute
3+5 minutes
4+30 minutes
5+2 hours
6+12 hours
7+24 hours

Each retry re-signs the payload with a fresh sentAt (not the original attempt's timestamp) but the same eventId every time - dedupe on eventId and a retry of something you already processed is a no-op, not a re-run. Check Developer → Webhook delivery logs for attempt count and last error on any delivery, and still poll the status endpoints as a fallback in case all 7 attempts are exhausted (e.g. your endpoint was down for more than a day).

Reference

Error Codes

Every error response follows the same shape: { "success": false, "message": "..." }.

HTTP statusWhen it happens
400Missing/invalid fields, insufficient balance, business-rule violation — read message for specifics
401Missing, invalid, or expired x-api-key
403Account deactivated, Payin/Payout disabled for your account, IP not allowlisted, or the key's scope doesn't cover this endpoint
404Order/payout ID doesn't exist (or doesn't belong to your account)
409Duplicate referenceId on a payout — see Create a Payout's idempotency notes. (Payin never returns 409 for this - a colliding referenceId gets a 200 instead, either the replay or a "still processing" response.)
500Something broke on our end while creating a payin/payout — see the warning below before retrying
503No active gateway can currently handle this — safe to retry shortly, same referenceId rule as 500 applies to Create calls

Retrying safely

On a 500/503/timeout from POST /payin or POST /payout, you don't actually know whether the order was created before the failure happened — the request may have succeeded on our end and only the response back to you was lost. Never retry with a newly-generated referenceId. Always retry with the exact same referenceId you used the first time: if the original attempt did go through, you'll get back 200 (idempotent replay) instead of creating a second order; if it didn't, a new one is created normally. A fresh referenceId on retry defeats the entire purpose of idempotency and can double-charge a customer or double-pay a beneficiary.

This is exactly why referenceId matters even on a first attempt that seems to be going fine — you can't predict which call will be the one that times out.

Reference

API Explorer

This explorer calls the same production API endpoint documented on every other page here - there's no separate "sandbox URL". Your API key is what determines Test vs. Live mode, not the endpoint: paste a pk_test_... key below to keep this to gateways in Test mode where no real money moves; a pk_live_... key runs for real.

Your API key never leaves your browser except to call api.techzopp.com directly — this page makes no calls to any other server. Paste your Test key (from Developer → API security) here to try things safely — it can't reach a Live gateway.