https://katanapay.co · 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 Key + Salt, 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.
| Field | Meaning |
|---|---|
Key | Public-ish identifier (mk_…) sent with every request. Identifies which merchant the order belongs to. |
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 (recommended) or the legacy SHA-512 format (§4). |
| Purpose | Method | URL |
|---|---|---|
| Create order (server-to-server) | POST | https://katanapay.co/api/v1/katana-pay/order |
| Hosted payment page (browser) | GET | https://katanapay.co/pay/{order_id} |
| Status enquiry | GET | https://katanapay.co/api/pay-status/{order_id} |
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.
| 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",
"order": { "id": "8d20c0b6-…", "order_id": "ORDER-1001", "amount": 100, "status": "PENDING", … },
"pay_url": "https://katanapay.co/pay/8d20c0b6-…", // redirect the customer here
"deeplinks": { "upi": "upi://pay?…", … },
"upi_intent": "upi://pay?…",
"qr_payload": "upi://pay?…"
}
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).
| Error | Meaning |
|---|---|
401 invalid key | Unknown Key. |
401 signature mismatch | Hash didn't match — check your signing string. |
403 | Account blocked. |
400 invalid amount | Amount missing / not positive. |
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
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. 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.
{
"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"
}
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.
ORDER_ID may arrive more than
once (a retry, or a status that settles twice). Key your handler on ORDER_ID and make a second
delivery a no-op.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", "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.co/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.
| 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. |
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.
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.co/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.co/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.co/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;
}
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.