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
| Capability | Endpoint |
|---|---|
| Collect a payment from a customer | POST /payin |
| Check whether a payin succeeded | GET /payin/:orderId/status |
| Send money to a bank account or UPI ID | POST /payout |
| Check a payout's status | GET /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.
403. See Authentication → IP whitelisting.Authentication
Every request is authenticated with a single header carrying your API key. There's no OAuth flow — just the header below.
Header
| Header | Value |
|---|---|
x-api-key | Your 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.
| Key | Prefix | What it can reach |
|---|---|---|
| Live key | pk_live_... | Only gateway accounts your admin has marked Live mode — moves real money |
| Test key | pk_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
| Status | Meaning |
|---|---|
401 | Missing, invalid, or expired x-api-key |
403 | Account 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.
| Step | What happens |
|---|---|
| Submit your server's IP | In 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 submit | Approved immediately. No admin step, no waiting — you can call the API right away from that IP. |
| Submitting a different IP later | Held 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 time | Exactly 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. |
403 while your old IP keeps working.Sandbox & Live
One base URL for both — which one you're in depends entirely on which API key you send.
How it behaves
| Sandbox (use your Test key) | Live (use your Live key) | |
|---|---|---|
| Base URL | https://api.techzopp.com/api/v1 (same) | |
| Header | x-api-key: pk_test_... | x-api-key: pk_live_... |
| Money movement | No real money moves | Real money moves |
| If no matching gateway exists | 503 — ask your admin to mark a gateway Test mode | 503 — no active gateway available |
| Response format | Identical 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.
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.
{"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
| Field | Type | Required | Description |
|---|---|---|---|
amount | integer | Required | Amount 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. |
referenceId | string | Required | Your 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. |
returnUrl | string | Optional | Your 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.
{
"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.
{
"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" }
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
| Field | Description |
|---|---|
orderId | The 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 value | Meaning |
|---|---|
pending | Customer hasn't completed payment yet |
success | Payment completed, wallet credited |
failed | Payment 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" }
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 returns the existing payout instead of creating a duplicate — safe to retry on timeout.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
amount | integer | Required | Amount 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. |
referenceId | string | Required | Your own unique idempotency key |
beneficiaryName | string | Required | Name on the receiving account |
beneficiaryAccountNumber | string | Optional* | 9-18 digit bank account number (for IMPS/NEFT/RTGS) - requires beneficiaryIfsc in the same request, it's not accepted alone |
beneficiaryIfsc | string | Optional* | Must match the standard IFSC format: 4 letters, then 0, then 6 letters/digits (e.g. SBIN0001234) - required together with beneficiaryAccountNumber |
beneficiaryUpi | string | Optional* | UPI ID in name@bank form, if paying out via UPI instead of bank account |
requestedMode | string | Optional | IMPS / 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).
{ "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" }
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 value | Meaning |
|---|---|
pending | Held from your wallet, awaiting admin confirmation of the bank/UPI transfer |
success | Admin 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. |
failed | Rejected 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" }
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
| Event | Fires when |
|---|---|
payin.success | A payin completes and your wallet is credited |
payin.failed | The 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.success | An admin confirms a payout with a UTR and your wallet debit is finalized |
payout.failed | An admin rejects a payout - the held amount is refunded to your wallet at the same time |
*.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
| Header | Value |
|---|---|
X-Webhook-Event | The event name, e.g. payin.success |
X-Webhook-Signature | HMAC-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:
| Attempt | Sent |
|---|---|
| 1 | Immediately |
| 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).
Error Codes
Every error response follows the same shape: { "success": false, "message": "..." }.
| HTTP status | When it happens |
|---|---|
400 | Missing/invalid fields, insufficient balance, business-rule violation — read message for specifics |
401 | Missing, invalid, or expired x-api-key |
403 | Account 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 |
404 | Order/payout ID doesn't exist (or doesn't belong to your account) |
409 | Duplicate 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.) |
500 | Something broke on our end while creating a payin/payout — see the warning below before retrying |
503 | No active gateway can currently handle this — safe to retry shortly, same referenceId rule as 500 applies to Create calls |
Retrying safely
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.
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.
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.