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 Techzopp dashboard → Developer → API 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.

A key alone isn't enough to call the API — you also need to whitelist your server's IP first, or every request fails with 403. See Authentication → IP whitelisting.
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, no IP whitelisted yet (see below), request IP doesn't match your whitelisted IP, or the key doesn't have the scope this endpoint requires

IP whitelisting (required)

Every request must come from your whitelisted IP — there is no "no restriction" state. Until an IP is whitelisted for your account, every call to this API (Payin, Payout, and status checks alike, live or test) is rejected with 403. 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.

StepWhat happens
Submit your server's IPIn Developer → IP whitelist, enter the public IP your backend calls this API from and hit Submit. Only Indian IPs are accepted — anything else is rejected on submission.
First IP you ever submitApproved immediately. No admin step, no waiting — you can call the API right away from that IP.
Submitting a different IP laterHeld as pending until your platform admin approves it. Your previously-approved IP keeps working the whole time — nothing breaks while you wait.
At any given timeExactly one IP is approved. Approving a new one replaces the old one immediately — the old IP stops working the moment the new one is approved.
If your backend's IP changes (new server, new hosting, IP rotation), submit the new IP before you switch traffic over, and ask your admin to approve it — until then, calls from the new IP fail with 403 while your old IP keeps working.
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

Test mode never moves real money, so you can use any syntactically valid card number or UPI ID to complete a checkout — contact support if you need a guaranteed-success or guaranteed-failure test value for a specific scenario.

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. Your admin can also set a per-account minimum and maximum for Payin - an amount outside that range gets 400 with a message naming the actual limit.
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.
returnUrlstringOptionalYour own page to send the customer's browser back to once they finish paying. Must be a valid http(s) URL. Recommended for every integration - without it, the customer lands on a generic result page instead of back on your site once checkoutUrl (see below) finishes. We send the browser to returnUrl with ?status=success|failed&orderId=... appended (using & if your URL already has a ?).

That's the whole request - checkout's guts (widget keys, form fields, whatever a given payment method needs) never pass through you. You get back one checkoutUrl; send the customer's browser there (a redirect is simplest) and it handles the rest, then lands them back on returnUrl.

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",
    "returnUrl": "https://yourstore.com/orders/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", returnUrl: "https://yourstore.com/orders/4471" })
});
const data = await res.json();

Success response

201 Created
{
  "success": true,
  "data": {
    "checkoutMode": "embedded",
    "checkoutUrl": "https://checkout.techzopp.com/checkout/order_ABC123",
    "testMode": false,
    "orderId": "api_1721234567890_a1b2c3",
    "amount": 50000,
    "currency": "INR"
  }
}

Redirect the customer's browser to checkoutUrl - that's the entire integration on your end. checkoutMode is informational only ("embedded"/"redirect"/"upi_link", describing how that page will behave) - you don't need to branch on it for anything.

200 OK (idempotent replay)
{
  "success": true,
  "message": "Already processed (idempotent replay)",
  "data": {
    "checkoutMode": "embedded",
    "checkoutUrl": "https://checkout.techzopp.com/checkout/order_ABC123",
    "testMode": false,
    "orderId": "api_1721234567890_a1b2c3",
    "status": "pending",
    "amount": 50000,
    "currency": "INR"
  }
}

Returned when you reuse a referenceId from an earlier call - note this includes the same checkoutUrl 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": { "checkoutMode": null, "checkoutUrl": null, "orderId": 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/checkoutUrl 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",
    "checkoutMode": "embedded",
    "amount": 50000,
    "currency": "INR",
    "status": "success",
    "merchantReferenceId": "order-4471",
    "checkoutUrl": "https://checkout.techzopp.com/checkout/order_ABC123"
  }
}

checkoutUrl is the same link 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. Once status is no longer pending it's a dead link, kept here only for reference.

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.

Failed response

200 OK
{
  "success": true,
  "data": {
    "orderId": "api_1721234567890_a1b2c3",
    "checkoutMode": "embedded",
    "amount": 50000,
    "currency": "INR",
    "status": "failed",
    "merchantReferenceId": "order-4471",
    "checkoutUrl": null,
    "failureReason": "Customer didn't complete the payment in time"
  }
}

failureReason is a short, plain-English reason - only present when status is failed and a reason is available (omitted entirely otherwise, not sent as null). It's a fixed, small set of merchant-facing strings (e.g. "Customer didn't complete the payment in time", "Customer cancelled the payment", "Payment was declined by the customer's bank", or a generic "Payment could not be completed" as a catch-all) - don't parse or pattern-match this text into your own logic, it's meant for display/support use, not as a machine-readable error code, and its wording may be refined over time.

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. Your admin can set a per-account minimum and maximum for Payout - an amount outside that range gets 400 with a message naming the actual limit.
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 a webhook URL in Developer → Webhook URLs to get pushed a signed event the moment a payin or payout succeeds, instead of polling.

Separate URL per direction, submit-then-approve

Payin and Payout each have their own webhook URL - one at a time, submitted by you, not a list. Submitting your first-ever URL for a direction is approved immediately; submitting a different one later is held pending until your admin approves it, and your previously-approved URL keeps receiving deliveries the whole time. This is the same submit-then-approve pattern as IP whitelisting, applied separately to each direction.

Only https:// URLs are accepted, and the URL is re-validated against internal/private addresses on every single delivery attempt (not just once at submission) to prevent it being pointed at internal infrastructure.

Events we send

EventFires when
payin.successA payin completes and your wallet is credited
payin.failedThe gateway reports the payin failed/expired - either via its own webhook, or because a call to Check Payin Status triggered us to re-check with the gateway
payout.successAn admin confirms a payout with a UTR and your wallet debit is finalized
payout.failedAn admin rejects a payout - the held amount is refunded to your wallet at the same time
A *.failed event only fires once we actually know about the failure. If a customer just abandons a payin without the gateway ever reporting anything back to us (no gateway webhook, and you never call Check Payin Status to trigger our own re-check), it can sit as pending indefinitely with no webhook either way. Don't rely on a webhook alone to conclude something failed — poll Check Payin Status after a reasonable timeout with no success webhook, treating "no webhook yet" as inconclusive rather than as failure.

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,
    "checkoutMode": "embedded",
    "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.

Example payload — payin.failed

{
  "eventId": "evt_7a1c4f92e0b83d56a9c1f47e2b0d8a63",
  "event": "payin.failed",
  "data": {
    "orderId": "api_1721234567890_a1b2c3",
    "referenceId": "order-4471",
    "amount": 50000,
    "status": "failed",
    "failureReason": "Customer didn't complete the payment in time"
  },
  "sentAt": "2026-07-20T09:45:12.000Z"
}

failureReason is a short, plain-English reason - same fixed set of strings as the failureReason on Check Payin Status, and only present when the gateway gave us one. Don't parse it into your own logic - it's for display/support use.

Example payload — payout.success

{
  "eventId": "evt_3b7a0c8f1d4e6a29b5c8f01d3e6a9b7c",
  "event": "payout.success",
  "data": {
    "payoutId": "66a1f...d221",
    "referenceId": "order-4471-payout",
    "amount": 100000,
    "beneficiaryName": "Amit Kumar",
    "utrNumber": "SBIN2026072012345",
    "status": "success"
  },
  "sentAt": "2026-07-20T11:42:08.000Z"
}

Fires once your admin confirms the bank transfer with a UTR - referenceId is the same value you sent when creating the payout, so match on that (not payoutId, which you won't have seen before this webhook arrives) to correlate it to your original POST /payout call. utrNumber is the bank's own reference for the transfer - worth storing for reconciliation against your bank statement.

Example payload — payout.failed

{
  "eventId": "evt_5d2e8a17c4f906b3a1e7d0c9f2b8e4a5",
  "event": "payout.failed",
  "data": {
    "payoutId": "66a1f...d221",
    "referenceId": "order-4471-payout",
    "amount": 100000,
    "beneficiaryName": "Amit Kumar",
    "status": "failed",
    "failureReason": "Invalid IFSC code on the beneficiary account"
  },
  "sentAt": "2026-07-20T11:50:44.000Z"
}

Fires when your admin rejects a payout - the full held amount (original amount + your payout commission) is refunded to your wallet at the same moment this webhook is sent, so you don't need to separately poll your wallet balance to confirm the refund. failureReason is the admin's own rejection remark, in whatever wording they entered - not a fixed set of strings like the other failureReason fields on this page, so treat it as free text for display, not something to branch logic on.

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, no IP whitelisted yet or request IP doesn't match your whitelisted IP (see Authentication → IP whitelisting), 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.