https://katanapay.tech · API v1return_url. You verify every signature with the
Key + Salt issued to your merchant account.
Every merchant account gets its own dedicated Key and Salt, issued from the Katana dashboard (Integration → Generate, or ask your Katana account manager). They are not shared between merchants, and every order and callback is bound to the pair that signed it.
| Pair | Key starts with | Orders it creates |
|---|---|---|
| Test | mk_test_ | Test orders: they pay a sandbox UPI ID, never move real money and never appear in live totals, statements or settlement (§11). |
| Live | KT_live_ | Real payments. Live Keys issued before 26 Oct 2026 start mk_live_ and keep working. |
The Key you sign with decides the mode; there is no mode field to send. Keys issued before test mode
(mk_ followed by hex) are live and keep working unchanged.
| Field | Meaning |
|---|---|
Key | Public-ish identifier (mk_test_… / KT_live_…) sent with every request. Identifies the merchant and the mode. |
Salt | Secret. Shown only once at issue. Used to sign your requests and to verify our callbacks. Never expose it in client-side code. |
Scheme | Signing algorithm for your account: HMAC_SHA256. Every new pair is issued with it; pairs issued earlier with the legacy SHA-512 format (§4) keep working until regenerated. |
| Purpose | Method | URL |
|---|---|---|
| Create order (server-to-server) | POST | https://katanapay.tech/api/v1/katana-pay/order |
| Hosted payment page (browser) | GET | https://katanapay.tech/pay/{order_id} |
| Status enquiry | GET | https://katanapay.tech/api/pay-status/{order_id} |
| Customer's UTR for a P2P order (§7) | POST | https://katanapay.tech/api/v1/p2p/utr |
Katana takes pay-ins on two flows, and your account is set up for P2P, Intent or Both:
| Flow | How the customer pays | Order API | Status |
|---|---|---|---|
| P2P | A UPI link or QR that pays the receiving UPI ID directly. Confirmed by the bank credit. | POST /api/v1/p2p/order | GET /api/v1/p2p/order/{id} |
| Intent | The payment is issued and confirmed by the payment gateway. | POST /api/v1/intent/order | GET /api/v1/intent/order/{id} |
All three order APIs take the same request, the same signature and return the same response. The general
/api/v1/katana-pay/order uses the flow selected for your account, so an existing integration keeps working
unchanged. Call a flow's own API when you want that flow by name. The response says which flow the order took in
"flow" (P2P or INTENT).
A flow your account is not set up for is refused with HTTP 409 and a code:
FLOW_NOT_ENABLED (your account is on the other flow), FLOW_NOT_SELECTED (no flow has been
selected yet) or FLOW_NOT_READY (the flow is selected but its setup is not complete). No order is created.
The flow status APIs accept the order id or the flow reference (P2P-000000123 /
INT-000000123) and add flow and that reference to the usual status fields.
POST /api/v1/katana-pay/order with a JSON body (form-encoded is also accepted). Idempotent on
txnid — re-sending the same txnid returns the existing order rather than creating a
second one, so a retry after a network timeout is always safe. A txnid is unique per mode: the same
reference sent with a test Key and a live Key creates two separate orders.
| Field | Req | Description |
|---|---|---|
key | yes | Your Key. |
txnid | yes | Your unique order reference (≤ 60 chars). This is your handle on the payment forever — see §9. |
amount | yes | Amount in rupees, e.g. "100" or "100.50". |
hash | yes | Signature over the order (see §4). |
productinfo | no | Order description (part of the signature). |
firstname | no | Customer name (part of the legacy SHA-512 signature). |
email | no | Customer email (part of the signature). |
phone | no | Customer phone. |
return_url | no | Browser redirect target after payment. Overrides your saved default. |
notify_url | no | Server-to-server status-callback target. Overrides your saved webhook URL. |
currency | no | Defaults to INR. |
mode | no | QR or INTENT. |
return_url and notify_url must be http(s) URLs. Other schemes are rejected.{
"verified": true,
"merchant": "UK-108", // the account that holds the order
"signed_by": "UK-108", // the account whose Key signed it
"livemode": true, // false = created with a test Key
"order": { "id": "8d20c0b6-…", "order_id": "ORDER-1001", "amount": 100, "status": "PENDING", … },
"checkout": "H2H", // H2H: UPI link + QR below; REDIRECT: send to pay_url
"pay_url": "https://katanapay.tech/pay/8d20c0b6-…", // redirect the customer here
"deeplinks": { "upi": "upi://pay?…", … },
"upi_intent": "upi://pay?…",
"qr_payload": "upi://pay?…"
}
merchant then says which account took it; signed_by is the Key you signed with. The callback still comes to your usual address, signed with the Salt you signed the order with, and retrying with the same Key and txnid finds the same order.Take pay_url and send the browser there. If you are building your own payment screen instead, use
qr_payload (render as a QR) or upi_intent (an app deeplink).
checkout says whether you can: H2H (host-to-host) means the UPI link and QR are in the
answer; REDIRECT means they are null and the customer pays on the hosted page. It depends on the
payment account behind your channel; ask Katana for host-to-host if your integration needs it. A test order always
carries the sandbox UPI link, and adds live_checkout: what your live orders will get (H2H,
REDIRECT, or null while no payment account is set up).
pay_apps)On some P2P accounts a payment can only be made from the Paytm or PhonePe app, through a link made for that
order. Their live orders are still H2H, but carry pay_apps instead of a UPI link:
upi_intent, qr_payload and deeplinks.upi are null.
{
…,
"checkout": "H2H",
"pay_url": "https://katanapay.tech/pay/8d20c0b6-…",
"upi_intent": null, "qr_payload": null,
"deeplinks": { "upi": null, "paytm": "paytmmp://…", "phonepe": "phonepe://…" },
"pay_apps": [
{ "app": "PAYTM", "name": "Paytm", "link": "paytmmp://…", "android_link": "intent://…;package=net.one97.paytm;end" },
{ "app": "PHONEPE", "name": "PhonePe", "link": "phonepe://…", "android_link": "intent://…;package=com.phonepe.app;end" }
],
"gateway_url": "https://katanapay.tech/pay/8d20c0b6-…/go" // when present: the processor's own payment page
}
If the answer also has gateway_url, you may send the customer there instead of showing the
buttons (a top-level redirect in the same tab): it opens the processor's own payment page for this order. Use one or
the other for an order, not both.
To take the payment on your own checkout page:
pay_apps, in that order, and nothing else: no QR, no "any UPI app",
no Google Pay, no UPI ID to copy. A payment made any other way reaches the account without the order's reference
and is not confirmed.android_link on Android and to
link on iOS. Use each link exactly as given; do not rebuild it from its parts.pay_url
(or show it as a QR to open on the phone).pay_apps; live_checkout
is H2H for these accounts. Make your page handle both answers: pay_apps when present,
else qr_payload / upi_intent.Some merchants collect through a gateway that takes the payment on its own secure page. Their orders have no UPI link: checkout is REDIRECT, deeplinks, upi_intent and
qr_payload are null, and one field is added:
{
…,
"checkout": "REDIRECT",
"pay_url": "https://katanapay.tech/pay/8d20c0b6-…", // still the page to send the customer to
"deeplinks": null, "upi_intent": null, "qr_payload": null,
"gateway_url": "https://katanapay.tech/pay/8d20c0b6-…/go" // straight to the gateway's page, if you'd rather skip ours
}
Send the customer to pay_url as usual — it shows the order and hands over to the gateway — or
straight to gateway_url. The result reaches you the same way (callback, status enquiry). The gateway's
own limits apply, e.g. a minimum amount per payment; an order outside them is refused with 502 and the
gateway's reason.
| Error | Meaning |
|---|---|
401 invalid key | Unknown Key. |
401 signature mismatch | Hash didn't match — check your signing string. |
403 | Account blocked or suspended (code MERCHANT_BLOCKED / MERCHANT_SUSPENDED), or set up for payouts only (code PAYIN_NOT_ENABLED). |
400 invalid amount | Amount missing / not positive. |
409 TXNID_IN_USE | Banker switch only: the account the order was offered to already has this txnid from another Key, and no other account could take it. Use a new txnid. |
409 | This account can't take live payments yet (no payment gateway or receiving UPI ID is set up). Contact your Katana account manager. |
502 | The payment gateway refused the order — the message gives its reason. No order was created; fix and retry with the same txnid. With code GATEWAY_CREDENTIALS the account's payment credentials need updating on Katana's side: send us the reference in the message. |
422 | The order is outside your account's limits. No order was created. The body carries code, field, limit and actual — see below. |
429 RATE_LIMITED | Too many orders in one second. Send it again after the Retry-After header (1 second). |
Limits. A live order outside your account's limits is refused with 422:
{ "error": "amount is above the maximum of ₹5,000", "code": "AMOUNT_ABOVE_MAX", "field": "amount", "limit": 5000, "actual": 5000.01 }
code | Meaning |
|---|---|
AMOUNT_BELOW_MIN | The amount is under the minimum for one order: your account's own, or the minimum of the payment account behind it (some take ₹500 or ₹1,000 and more per payment). limit is the minimum that applies. |
AMOUNT_ABOVE_MAX | The amount is over your account's maximum for one order. |
UPI_LIMIT_EXCEEDED | The amount is over what one UPI payment can carry (₹1,00,000 unless your account has a higher maximum). |
DAILY_LIMIT_EXCEEDED | Today's orders plus this one would pass your daily limit; actual is the total it would reach. The day is the calendar day in India, and an order that fails or expires gives its amount back. |
Repeating a txnid you already created is never limited: you get the original order back.
Request id. Every response carries an X-Request-Id header. Send your own (8 to 64 letters, digits,
., _, : or -) and it is echoed and kept with the order; otherwise one is generated. Quote it when you contact support.
Compute hash with the scheme configured on your account (shown next to your Key in the
dashboard), then send it as the hash field. You never send the scheme name — Katana already knows which
one your account uses.
message = txnid + "|" + amount + "|" + productinfo + "|" + email hash = HMAC_SHA256( key = KEY + SALT , message ) // lowercase hex
Only for pairs issued before HMAC_SHA256 became the only scheme; a regenerated pair is always HMAC_SHA256. Used by accounts migrated from an older checkout. Pipe-joined, with five user-defined fields and five reserved blanks before the salt:
seq = KEY|txnid|amount|productinfo|firstname|email|||||||||||SALT // 5 udf + 5 reserved blanks hash = SHA512(seq) // lowercase hex
Send the customer's browser to the pay_url from the response. The hosted page shows a UPI QR plus
app buttons and live status (or, for a merchant paid on a gateway's page, the order and a Pay button that opens it). When the payment is confirmed it automatically redirects back to your
return_url with query params:
https://your-site.com/return?order_id=ORDER-1001&status=SUCCESS&rrn=123456789012
On every terminal status Katana POSTs a JSON body to your notify_url (or your saved webhook
URL). Reply HTTP 200. Delivery is queued and retried with backoff until you do, and every
attempt is recorded — so a callback is never silently dropped.
Katana also checks your callback URL now and then with a made-up order (ORDER_ID
integration-check, header X-Katana-Check: 1). Answer it 200, or reject it as an
unknown order (404 is fine); never act on it.
{
"PAY_ID": "…", "ORDER_ID": "ORDER-1001", "TXN_ID": "…",
"AMOUNT": "100", "CURRENCY_CODE": "356",
"STATUS": "Captured", "RESPONSE_CODE": "000",
"RRN": "123456789012", "RESPONSE_DATE_TIME": "2026-07-01T…Z",
"HASH": "716346F5…EC21"
}
The body has the same fields whatever happened. These are the three statuses a callback can carry, and the second callback of an order that is paid late:
// Paid
{ "ORDER_ID": "ORDER-1001", "STATUS": "Captured", "RESPONSE_CODE": "000", "RRN": "123456789012", … }
// Failed: the payment was declined. RRN is an empty string.
{ "ORDER_ID": "ORDER-1002", "STATUS": "Failed", "RESPONSE_CODE": "004", "RRN": "", … }
// Expired: no payment arrived in time. RRN is an empty string.
{ "ORDER_ID": "ORDER-1003", "STATUS": "Expired", "RESPONSE_CODE": "003", "RRN": "", … }
// The same order, paid after it expired: a SECOND callback, some time later
{ "ORDER_ID": "ORDER-1003", "STATUS": "Captured", "RESPONSE_CODE": "000", "RRN": "123456789013", … }
In the portal, Orders finds any order by its order id, your txnid or the bank reference and
shows every callback attempt with what your server answered; Resend sends one again, and
Send test event sends a sample of each status to your callback URL.
HASH.KEY=value with ~ between pairs.SALT directly to the end of that string.SHA256 it → hex → UPPERCASE. It must equal HASH.Paid = STATUS is Captured and RESPONSE_CODE is 000.
"LIVEMODE": "false"
(included in the HASH like any other field) and is signed with the test Salt. Live callbacks omit the field.
Verify with the Salt of the matching mode, and never fulfil a real order from a LIVEMODE=false callback.ORDER_ID may arrive more than
once (a retry). Key your handler on ORDER_ID + STATUS and make a repeat of the same
status a no-op.Expired and Failed can be followed by Captured.
If the customer's payment goes through after the order expired, or after an earlier attempt failed, Katana honours
it: the order becomes SUCCESS and a second callback with STATUS = "Captured" is
sent. Let Captured overwrite an earlier Expired or Failed.
Captured is final — nothing is ever sent after it.At any time: GET /api/pay-status/{order_id}
{ "order_id": "ORDER-1001", "amount": 100, "currency_code": "INR",
"status": "SUCCESS", "terminal": true, "rrn": "123456789012",
"mode": "QR", "livemode": true, "return_url": "…" }
terminal: true means the order has reached a final state and will not change again.
?wait=1Add ?wait=1 and the request is held open until the order becomes terminal (up to ~25s), instead
of returning the current state immediately. This lets your own page flip to "Payment received" within about half a
second of the money landing, without polling in a tight loop.
GET https://katanapay.tech/api/pay-status/8d20c0b6-…?wait=1
Use plain enquiry (no wait) for reconciliation sweeps, and wait=1 only where a person is
watching a screen. Either way, enquiry is the fallback if a callback was missed — it always reflects the truth.
POST /api/v1/reports/payins returns your own orders for a date range, with totals.
| Field | Meaning |
|---|---|
key | Your Key. A test Key reports test orders, a live Key live ones. |
from, to | Calendar days in India, YYYY-MM-DD. At most 31 days. |
format | json (default) or csv. |
hash | Signed like an order, over the string from|to. |
The JSON carries summary (orders, paid orders, paid amount, failed, expired, pending, success rate),
by_day, by_flow and orders (your txnid, the order id, amount,
status, flow, utr, created_at, paid_at). status is the same word your
callback carries: Captured, Failed, Expired, or Pending. A report holds up to 10,000 orders;
truncated: true means the range has more, and the totals still cover all of them.
Every report carries an X-Report-Hash header, the SHA-256 of its content (the JSON repeats it as
report_hash). Keep it with the file: a copy that has been edited no longer matches.
On P2P, the customer pays the receiving UPI ID and Katana confirms the order when that payment reaches it, usually within seconds. When it doesn't (the customer paid late, or several orders of the same amount were open), your customer has a 12-digit UTR (UPI reference number) in their UPI app. Send it here and the order is confirmed by it:
POST https://katanapay.tech/api/v1/p2p/utr
{ "key": "KT_live_…", "txnid": "ORDER-1001", "utr": "629655704289", "hash": "…" }
| Field | Meaning |
|---|---|
key | Your Key: the one the order was created with. |
txnid or order_id | Your order reference (txnid), or Katana's order id from the order response. Send one, not both. |
utr | The 12-digit UTR from the customer's UPI app (Google Pay: "UPI transaction ID"; PhonePe: "UTR"; Paytm: "UPI Ref No"). |
hash | HMAC-SHA256(key + salt, "<txnid or order_id>|<utr>"), hex. Legacy pairs: sha512(key|<txnid or order_id>|<utr>|salt). |
A UTR alone never confirms an order. It is checked against the payment Katana actually received: same UTR, same amount, on your account, made while the order existed. So a customer cannot use someone else's UTR or a made-up one.
| HTTP | result | Meaning |
|---|---|---|
| 200 | CONFIRMED | The payment with this UTR was found. The order is paid and your Captured callback is on its way (also when the order had already expired). |
| 202 | WAITING | The UTR is recorded but that payment hasn't reached Katana yet. The order is confirmed, and your callback sent, the moment it does. Nothing more to send. |
| 4xx | REFUSED | With a code below. The order is unchanged. |
| HTTP | code | Meaning |
|---|---|---|
| 400 | INVALID_REQUEST | A field is missing or wrong, or both txnid and order_id were sent. |
| 400 | INVALID_UTR | Not a 12-digit UTR. |
| 401 | SIGNATURE_MISMATCH | The hash is wrong; hint says what is signed. |
| 403 | LIVE_MODE_NOT_ACTIVATED | A live Key before live mode is activated. |
| 404 | ORDER_NOT_FOUND | No order with this reference was created with this Key (in this mode). |
| 409 | ORDER_FAILED | The order has failed and can't be paid. |
| 409 | UTR_ALREADY_USED | This UTR has already confirmed another order. |
| 409 | AMOUNT_MISMATCH | The payment with this UTR is for a different amount than the order. |
| 409 | PAID_BEFORE_ORDER | The payment with this UTR was made before the order was created. |
| 409 | PAID_TOO_LATE | The payment was made too long after the order was created; support will check it. |
| 422 | UTR_NOT_APPLICABLE | The order is confirmed by its payment processor (Intent), so no UTR is needed. |
| 429 | TOO_MANY_TRIES | Too many UTRs sent for one order (6). Send the payment screenshot to support. |
Every answer carries txnid, order_id, utr, order_status and livemode.
With a test Key, any 12-digit UTR confirms the open test P2P order, so you can see the callback without money.
// Node.js
const sig = crypto.createHmac("sha256", KEY + SALT).update(`${txnid}|${utr}`).digest("hex");
const r = await fetch("https://katanapay.tech/api/v1/p2p/utr", { method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ key: KEY, txnid, utr, hash: sig }) });
| STATUS | RESPONSE_CODE | Terminal | Meaning |
|---|---|---|---|
| Captured | 000 | yes | Paid — fulfil the order. |
| Pending | 005 | no | Awaiting payment. |
| Failed | 004 | yes * | Declined / failed. |
| Expired | 003 | yes * | Payment request lapsed. |
* Failed and Expired end the order unless the payment goes through afterwards; a
Captured callback then follows (see §6). Captured never changes.
Every pay-in carries four identifiers. Store all of them against your order; each answers a different question.
| Identifier | Who creates it | Use it to |
|---|---|---|
txnid / ORDER_ID | You, on create | Your own reference. Idempotency key on create, and the key you match callbacks on. |
order.id (UUID) | Katana | Address the hosted page (/pay/{id}) and the status endpoint. |
PAY_ID | Katana | Quote to support — it identifies the payment inside Katana. |
RRN / UTR | The UPI network | The 12-digit bank reference. This is what a bank statement shows, so it is the identifier that settles disputes. |
create order → status PENDING (you hold txnid + order.id)
customer pays by UPI → credit confirmed
→ status SUCCESS, RRN filled in
→ signed callback POSTed to your notify_url (retried until 200)
→ customer redirected to your return_url
A payment that never completes ends Expired; one that fails ends Failed. Both are terminal and both
fire a callback, so your side never has to guess. If the money arrives afterwards all the same, the order moves to
SUCCESS and a Captured callback follows.
GET /api/pay-status/{order_id} is the authoritative answer at that moment.ORDER-1001?" from your own database alone. That is what storing the four identifiers above buys you.$key="mk_xxx"; $salt="your_salt";
$o = ["txnid"=>"ORDER-1001","amount"=>"100","productinfo"=>"Order 1001","email"=>"d@e.com"];
$msg = $o["txnid"]."|".$o["amount"]."|".$o["productinfo"]."|".$o["email"];
$o["hash"] = hash_hmac("sha256", $msg, $key.$salt);
$o["key"]=$key; $o["return_url"]="https://you.com/return"; $o["notify_url"]="https://you.com/callback";
$ch=curl_init("https://katanapay.tech/api/v1/katana-pay/order");
curl_setopt_array($ch,[CURLOPT_POST=>1,CURLOPT_RETURNTRANSFER=>1,
CURLOPT_HTTPHEADER=>["Content-Type: application/json"],CURLOPT_POSTFIELDS=>json_encode($o)]);
$res=json_decode(curl_exec($ch),true);
header("Location: ".$res["pay_url"]); // redirect customer
import crypto from "crypto";
const key="mk_xxx", salt="your_salt";
const o={ txnid:"ORDER-1001", amount:"100", productinfo:"Order 1001", email:"d@e.com" };
const msg=[o.txnid,o.amount,o.productinfo,o.email].join("|");
o.hash=crypto.createHmac("sha256",key+salt).update(msg).digest("hex");
const r=await fetch("https://katanapay.tech/api/v1/katana-pay/order",{method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({...o,key,return_url:"https://you.com/return",notify_url:"https://you.com/callback"})});
const data=await r.json(); // redirect customer to data.pay_url
import hmac,hashlib,requests
key,salt="mk_xxx","your_salt"
o={"txnid":"ORDER-1001","amount":"100","productinfo":"Order 1001","email":"d@e.com"}
msg="|".join([o["txnid"],o["amount"],o["productinfo"],o["email"]])
o["hash"]=hmac.new((key+salt).encode(),msg.encode(),hashlib.sha256).hexdigest()
o.update(key=key, return_url="https://you.com/return", notify_url="https://you.com/callback")
data=requests.post("https://katanapay.tech/api/v1/katana-pay/order",json=o).json()
# redirect customer to data["pay_url"]
function verify(body, salt){
const got=body.HASH; const f={...body}; delete f.HASH;
const str=Object.keys(f).sort().map(k=>`${k}=${f[k]==null?"":f[k]}`).join("~")+salt;
const exp=crypto.createHash("sha256").update(str,"utf8").digest("hex").toUpperCase();
return exp===got; // true → trust it; then check STATUS==="Captured"
}
function verify($body,$salt){
$got=$body["HASH"]; unset($body["HASH"]); ksort($body);
$parts=[]; foreach($body as $k=>$v){$parts[]="$k=".($v??"");}
$str=implode("~",$parts).$salt;
return strtoupper(hash("sha256",$str))===$got;
}
Integrate with the test pair first. Test orders behave exactly like live ones (hosted page, callbacks, status enquiry) except that they pay a sandbox UPI ID and the hosted page shows a Test payment label with Simulate success and Simulate failure buttons. Tap one and the order settles that way; your server receives the same signed callback a real payment sends. From your own code, the paise of the amount also force the outcome, so every branch can be tested without a real payer:
| Send amount | Order becomes | Use it to test |
|---|---|---|
100.99 | SUCCESS after ~8 s | Happy path, callback verification, fulfilment |
100.13 | FAILED | Decline handling |
100.11 | EXPIRED | Expiry handling |
100.00 | stays PENDING, expires after 15 min | Long-poll and your own re-check |
.99 behaves exactly like any other. Real bank credits never
confirm a test order.In the dashboard, the Test / Live switch in the header shows test orders; an orange banner stays on while you are viewing test data. Live totals, statements and settlement never include test orders.
403 and
"code": "LIVE_MODE_NOT_ACTIVATED".txnid, order.id, PAY_ID and RRN against your order (§9).ORDER_ID may arrive more than once.txnid.Katana Pay · integration support: contact your Katana account manager.