UPI Station API
OpenAPI JSON Get API keys

Introduction

API Reference

Welcome to the UPI Station API. This guide gives you everything you need to collect UPI payments from your application or website — create a payment request, send your customer the hosted payment link, and receive a signed webhook the moment the payment settles.

The API is a small, focused REST surface under /api/v1. All requests and responses are JSON, and every request is signed (see Authorization).

Workflow

A typical payment flows between the customer, your store, and UPI Station:

1
CustomerClicks “Pay” in your store
2
MerchantCreates a payment request → UPI Station
3
UPI StationReturns a hosted payment_link
4
CustomerCompletes the UPI payment
5
UPI StationSends a signed status webhook → Merchant

Authorization

Every request must send two headers:

FieldTypeDescription
x-key-idrequired string Your API key id (the usk_… value).
x-signaturerequired string HMAC-SHA256 signature of the raw request body, prefixed with v1=.

The signature is computed as follows:

signature algorithm
signingKey = SHA256("upi-station.api-signing-key.v1" + "\0" + key_secret)

preimage =
  upi-station.api-signature.v1
  key-id:<x-key-id>
  body-length:<utf8 byte length of raw body>
  <blank line>
  <raw request body>

x-signature = "v1=" + base64url( HMAC-SHA256(signingKey, preimage) )
Sign server-side only. Your key secret must never reach a browser or mobile client. Because the signature binds to the exact body bytes, you compute it on your backend for each request. Generate keys on the For Developers page.

Reference implementation

Copy this into your backend — it returns the x-key-id and x-signature headers for a request body. Sign the exact string you send as the body.

Node.js
import { createHash, createHmac } from "node:crypto";

// Returns the two headers to send for a given request body.
// `body` MUST be the exact string you send as the request body.
export function signRequest(body, keyId, keySecret) {
  const signingKey = createHash("sha256")
    .update("upi-station.api-signing-key.v1").update("\0").update(keySecret, "utf8")
    .digest();
  const preimage = [
    "upi-station.api-signature.v1",
    "key-id:" + keyId,
    "body-length:" + Buffer.byteLength(body, "utf8"),
    "",
    body,
  ].join("\n");
  const signature = createHmac("sha256", signingKey).update(preimage, "utf8").digest("base64url");
  return { "x-key-id": keyId, "x-signature": "v1=" + signature };
}

const body = JSON.stringify({ service_request_id: "UPIS260530040214346675" });
const headers = signRequest(body, "usk_your_key_id", "uss_your_key_secret");
// fetch("https://staging.upistation.com/api/v1/payment/requests/query", { method: "POST",
//   headers: { "content-type": "application/json", ...headers }, body });
Python
import base64, hashlib, hmac

def sign_request(body: str, key_id: str, key_secret: str) -> dict:
    """Return the two headers to send for an exact request body string."""
    signing_key = hashlib.sha256(
        b"upi-station.api-signing-key.v1\x00" + key_secret.encode()
    ).digest()
    preimage = "\n".join([
        "upi-station.api-signature.v1",
        f"key-id:{key_id}",
        f"body-length:{len(body.encode())}",
        "",
        body,
    ]).encode()
    digest = hmac.new(signing_key, preimage, hashlib.sha256).digest()
    signature = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
    return {"x-key-id": key_id, "x-signature": f"v1={signature}"}

# body MUST be the exact bytes you send as the request body
body = '{"service_request_id":"UPIS260530040214346675"}'
headers = sign_request(body, "usk_your_key_id", "uss_your_key_secret")

Quickstart

  1. Generate an API key on the For Developers page.
  2. Add the signing function to your backend to produce the x-key-id and x-signature headers.
  3. Create a payment request and read back the payment_link:
cURL
curl -sS -X POST https://staging.upistation.com/api/v1/payment/requests \
  -H 'content-type: application/json' \
  -H 'x-key-id: usk_your_key_id' \
  -H 'x-signature: v1=<base64url-hmac>' \
  -d '{ "client_request_id": "order-2026-0001", "client_customer_id": "cust_8842", "payment_system": "PAYTM", "amount": "100.00", "currency": "INR", "webhook_url": "https://merchant.example.com/upi/webhook" }'
  1. Redirect the customer to payment_link.
  2. Receive a signed request.status.changed webhook when it settles — or poll POST /api/v1/payment/requests/query.

Sandbox

Sandbox lets you validate your integration end to end — create a request, open the hosted checkout, and receive a signed request.status.changed webhook — without moving real money or triggering any UPI app. There is no separate URL, host, or environment: you call the same API at https://staging.upistation.com/api/v1, and your key decides the behaviour.

  • Sandbox keys start with usk_sandbox_. Generate one in For Developers → API Keys → choose Sandbox.
  • Live keys start with usk_ and process real payments.
  • Sandbox requests are free — no wallet balance is required and no fee is charged — and resolve to a mocked outcome automatically.

Choosing the outcome

Precedence: the x-sandbox-outcome header > notes.sandbox.outcome > the amount. By the amount's paise:

FieldTypeDescription
.00 (or anything else) PAID Payment succeeds.
.51 FAILED Payment fails.
.55 PENDING Never resolves (test the pending/timeout path).

Or force it explicitly with { "notes": { "sandbox": { "outcome": "failure", "delay_ms": 0 } } } on create, or per request with the header x-sandbox-outcome: failure. delay_ms controls how long the request stays PENDING before resolving.

What the create response contains

  • payment_link — a real hosted checkout page on this host: https://staging.upistation.com/pay/<service_request_id>. In sandbox it shows the order and resolves to the mocked outcome automatically (no real scan needed).
  • intent_url — a dummy deeplink (upi://pay?pa=sandbox@upistation…). It does not open a real payment; it only exists so your client code paths run.
  • app_intents — the same dummy intent pre-built per app.

Webhooks in sandbox

Configure a dedicated Sandbox webhook endpoint in For Developers → Webhooks (kept separate from your live endpoint), or pass a per-request webhook_url. Sandbox events are signed exactly like live ones and never reach your live handler.

Payment Request API

Create a Payment Request

POST/api/v1/payment/requests

Create an idempotent UPI payment request and obtain a hosted payment link.

Request body

FieldTypeDescription
client_request_idrequired string Your idempotency key for this request. Re-sending the same id returns the existing request instead of creating a duplicate.
client_customer_idrequired string Your stable identifier for the paying customer.
payment_systemrequired string Target payment system, e.g. PAYTM.
amountrequired string | number Amount to collect. Stored as a decimal string.
currency string Defaults to INR.
redirect_success_url string (uri) Where to send the customer after a successful payment.
redirect_return_url string (uri) Where to send the customer if they go back / cancel.
webhook_url string (uri) Endpoint that receives request.status.changed webhooks.
notes object | null Arbitrary metadata echoed back on the request and webhooks.
expires_in_minutes integer Minutes until the request expires.
cURL
curl -sS -X POST https://staging.upistation.com/api/v1/payment/requests \
  -H 'content-type: application/json' \
  -H 'x-key-id: usk_your_key_id' \
  -H 'x-signature: v1=<base64url-hmac>' \
  -d '{ "client_request_id": "order-2026-0001", "client_customer_id": "cust_8842", "payment_system": "PAYTM", "amount": "100.00", "currency": "INR", "webhook_url": "https://merchant.example.com/upi/webhook" }'
Request body
{
  "client_request_id": "order-2026-0001",
  "client_customer_id": "cust_8842",
  "payment_system": "PAYTM",
  "amount": "100.00",
  "currency": "INR",
  "webhook_url": "https://merchant.example.com/upi/webhook"
}

Response 200

FieldTypeDescription
service_request_idrequired string UPI Station's id for the request. Use it to query status.
client_customer_id string Echoed from the create request.
client_request_id string Echoed from the create request.
payment_system string The target payment system.
statusrequired string One of PENDING, PAID, FAILED, EXPIRED.
amount string Requested amount.
amount_paid string | null Captured amount once PAID, otherwise null.
payment_info object | null Settlement details (amount, payee_upi_id, payer_upi_id, payment_at, rrn) once PAID.
payment_link string (uri) Hosted page to send the customer to.
intent_url string | null Raw UPI intent deeplink (upi://pay?…) for apps that drive their own checkout instead of redirecting to payment_link.
app_intents object | null Per-app deeplinks (google_pay, phonepe, paytm, bhim) derived from intent_url. See the iOS note below.
status_updated_at string (date-time) When the status last changed.
expired_at string | null Expiry timestamp, if set.
notes object | null Echoed metadata.
Response
{
  "service_request_id": "UPIS260530040214346675",
  "client_customer_id": "cust_8842",
  "client_request_id": "order-2026-0001",
  "payment_system": "PAYTM",
  "status": "PENDING",
  "amount": "100.00",
  "amount_paid": null,
  "payment_info": null,
  "payment_link": "https://staging.upistation.com/pay/UPIS260530040214346675",
  "intent_url": "upi://pay?pa=merchant@upi&pn=Merchant&am=100.00&tr=UPIS260530040214346675",
  "app_intents": {
    "google_pay": "tez://upi/pay?pa=merchant@upi&pn=Merchant&am=100.00&tr=UPIS260530040214346675",
    "phonepe": "phonepe://pay?pa=merchant@upi&pn=Merchant&am=100.00&tr=UPIS260530040214346675",
    "paytm": "paytmmp://pay?pa=merchant@upi&pn=Merchant&am=100.00&tr=UPIS260530040214346675",
    "bhim": "bhim://upi/pay?pa=merchant@upi&pn=Merchant&am=100.00&tr=UPIS260530040214346675"
  },
  "status_updated_at": "2026-05-30T04:02:14.463Z",
  "expired_at": null,
  "notes": null
}

Driving your own checkout (intent_url / app_intents)

Most integrations simply redirect the customer to payment_link — our hosted page already handles QR, every UPI app, polling, and expiry. If you render your own checkout instead, use intent_url (the raw upi://pay?… intent) and app_intents (the same intent pre-built for each app).

iOS note. On Android, opening intent_url shows the system app chooser. iOS has no chooser for the upi:// scheme — it silently routes to a single app or nothing. To let an iOS customer pick an app you must open that app's own scheme from app_intents (e.g. phonepe://, tez://, paytmmp://, bhim://). A web page cannot detect which apps are installed, so always show all options plus a QR / copy-UPI-ID fallback.

Payment Request Status

POST/api/v1/payment/requests/query

Fetch the current status of a previously created request. Returns 404 if the service_request_id is unknown.

Request body

FieldTypeDescription
service_request_idrequired string The id returned by create.
cURL
curl -sS -X POST https://staging.upistation.com/api/v1/payment/requests/query \
  -H 'content-type: application/json' \
  -H 'x-key-id: usk_your_key_id' \
  -H 'x-signature: v1=<base64url-hmac>' \
  -d '{"service_request_id":"UPIS260530040214346675"}'

The response is the same Payment Request shape documented above.

Status Lifecycle

A request starts PENDING and moves to exactly one terminal state. Terminal states never transition again.

PENDING
PAIDPayment captured
FAILEDDeclined or errored
EXPIREDWindow elapsed
FieldTypeDescription
PENDING status Created, awaiting customer payment.
PAID status Payment captured; payment_info populated.
FAILED status Payment attempted but declined or errored.
EXPIRED status No payment before the expiry window elapsed.

Webhooks

Request status changed

POSTrequest.status.changed → your webhook_url

Sent to your configured webhook_url whenever a payment request changes status. The POST body is signed with x-signature using the same scheme as API requests; verify it with your key secret over the raw body.

At-least-once delivery. Any non-2xx response is retried up to 10 times with backoff, so your handler must be idempotent. Acknowledge with any 2xx.

Field naming differs from the query response: webhooks use payment_url (not payment_link) and expires_at (not expired_at).

Event body

FieldTypeDescription
service_request_idrequired string The request whose status changed.
client_customer_id string Your customer id.
client_request_id string Your idempotency key.
payment_system string The target payment system.
statusrequired string New status: PENDING, PAID, FAILED, EXPIRED.
amount string Requested amount.
amount_paid string | null Captured amount when PAID.
payment_info object | null Settlement details once PAID.
payment_urlrequired string (uri) Hosted payment page. Webhook-only field — the query response calls this payment_link.
intent_url string | null Raw UPI intent deeplink (upi://pay?…) for apps that drive their own checkout. Same value the query response returns as intent_url.
app_intents object | null Per-app deeplinks (google_pay, phonepe, paytm, bhim) derived from intent_url. See the iOS note below.
status_updated_at string (date-time) When the status changed.
expires_at string | null Expiry timestamp. Webhook-only field — the query response calls this expired_at.
notes object | null Echoed metadata.
Example webhook body
{
  "service_request_id": "UPIS260530040214346675",
  "client_customer_id": "cust_8842",
  "client_request_id": "order-2026-0001",
  "payment_system": "PAYTM",
  "status": "PAID",
  "amount": "100.00",
  "amount_paid": "100.00",
  "payment_info": {
    "amount": "100.00",
    "payee_upi_id": "merchant@paytm",
    "payer_upi_id": "customer@okhdfcbank",
    "payment_at": "2026-05-30T04:05:11.000Z",
    "rrn": "401512345678"
  },
  "payment_url": "https://staging.upistation.com/pay/UPIS260530040214346675",
  "intent_url": "upi://pay?pa=merchant@upi&pn=Merchant&am=100.00&tr=UPIS260530040214346675",
  "app_intents": {
    "google_pay": "tez://upi/pay?pa=merchant@upi&pn=Merchant&am=100.00&tr=UPIS260530040214346675",
    "phonepe": "phonepe://pay?pa=merchant@upi&pn=Merchant&am=100.00&tr=UPIS260530040214346675",
    "paytm": "paytmmp://pay?pa=merchant@upi&pn=Merchant&am=100.00&tr=UPIS260530040214346675",
    "bhim": "bhim://upi/pay?pa=merchant@upi&pn=Merchant&am=100.00&tr=UPIS260530040214346675"
  },
  "status_updated_at": "2026-05-30T04:05:11.000Z",
  "expires_at": null,
  "notes": null
}