Katana Pay — Gateway Integration Kit

Hosted-checkout UPI pay-ins for any platform · Base URL https://katanapay.co · API v1
What you'll do: your server creates a signed order → you redirect the customer to the Katana Pay hosted payment page → the customer pays by UPI → Katana POSTs a signed status callback to your server and redirects the customer back to your return_url. You verify every signature with the Key + Salt issued to your merchant account.

Contents

1. Credentials (Key + Salt) 2. Endpoints 3. Create an order 4. Sign the request 5. Redirect to the payment page 6. Receive the status callback 7. Status enquiry (and long-poll) 8. Status & response codes 9. Traceability — following one payment end to end 10. Sample code (PHP / Node / Python) 11. Go-live checklist

1. Credentials (Key + Salt)

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.

FieldMeaning
KeyPublic-ish identifier (mk_…) sent with every request. Identifies which merchant the order belongs to.
SaltSecret. Shown only once at issue. Used to sign your requests and to verify our callbacks. Never expose it in client-side code.
SchemeSigning algorithm for your account: HMAC_SHA256 (recommended) or the legacy SHA-512 format (§4).
Keep the Salt safe. If lost, regenerate — which invalidates the old pair, so update your server immediately. One active Key per merchant account.

2. Endpoints

PurposeMethodURL
Create order (server-to-server)POSThttps://katanapay.co/api/v1/katana-pay/order
Hosted payment page (browser)GEThttps://katanapay.co/pay/{order_id}
Status enquiryGEThttps://katanapay.co/api/pay-status/{order_id}
Your Key + Salt is the only credential on these endpoints. Treat Salt disclosure as a full compromise of your integration: regenerate immediately and reconcile every order created in the interim.

3. Create an order

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.

Request fields

FieldReqDescription
keyyesYour Key.
txnidyesYour unique order reference (≤ 60 chars). This is your handle on the payment forever — see §9.
amountyesAmount in rupees, e.g. "100" or "100.50".
hashyesSignature over the order (see §4).
productinfonoOrder description (part of the signature).
firstnamenoCustomer name (part of the legacy SHA-512 signature).
emailnoCustomer email (part of the signature).
phonenoCustomer phone.
return_urlnoBrowser redirect target after payment. Overrides your saved default.
notify_urlnoServer-to-server status-callback target. Overrides your saved webhook URL.
currencynoDefaults to INR.
modenoQR or INTENT.
return_url and notify_url must be http(s) URLs. Other schemes are rejected.

Response (HTTP 201 new / 200 reused)

{
  "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).

ErrorMeaning
401 invalid keyUnknown Key.
401 signature mismatchHash didn't match — check your signing string.
403Account blocked.
400 invalid amountAmount missing / not positive.

4. Sign the request

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.

HMAC_SHA256 (recommended)

message = txnid + "|" + amount + "|" + productinfo + "|" + email
hash    = HMAC_SHA256( key = KEY + SALT , message )      // lowercase hex

Legacy SHA-512 format

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

5. Redirect to the payment page

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
Always re-confirm server-side (callback or status enquiry) before fulfilling — the browser redirect is informational only and can be forged by anyone who can type a URL.

6. Receive the status callback

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.

Callback body

{
  "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"
}

Verify the HASH (with your Salt)

  1. Take every field except HASH.
  2. Sort the keys ascending; join as KEY=value with ~ between pairs.
  3. Append your SALT directly to the end of that string.
  4. SHA256 it → hex → UPPERCASE. It must equal HASH.

Paid = STATUS is Captured and RESPONSE_CODE is 000.

Treat callbacks as idempotent. The same 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.

7. Status enquiry (and long-poll)

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.

Long-poll: ?wait=1

Add ?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.

8. Status & response codes

STATUSRESPONSE_CODETerminalMeaning
Captured000yesPaid — fulfil the order.
Pending005noAwaiting payment.
Failed004yesDeclined / failed.
Expired003yesPayment request lapsed.

9. Traceability — following one payment end to end

Every pay-in carries four identifiers. Store all of them against your order; each answers a different question.

IdentifierWho creates itUse it to
txnid / ORDER_IDYou, on createYour own reference. Idempotency key on create, and the key you match callbacks on.
order.id (UUID)KatanaAddress the hosted page (/pay/{id}) and the status endpoint.
PAY_IDKatanaQuote to support — it identifies the payment inside Katana.
RRN / UTRThe UPI networkThe 12-digit bank reference. This is what a bank statement shows, so it is the identifier that settles disputes.

The lifecycle

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.

Reconciling a day

Reconciliation rule of thumb: your system should be able to answer "what happened to ORDER-1001?" from your own database alone. That is what storing the four identifiers above buys you.

10. Sample code

PHP — create order

$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

Node.js — create order

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

Python — create order

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"]

Verify a callback (Node.js)

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"
}

Verify a callback (PHP)

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;
}

11. Go-live checklist

Katana Pay · integration support: contact your Katana account manager.