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:
Quickstart
- Generate an API key on the For Developers page.
- Add the signing function to your backend to produce the
x-key-idandx-signatureheaders. - Create a payment request and read back the
payment_link:
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" }'
- Redirect the customer to
payment_link. - Receive a signed
request.status.changedwebhook when it settles — or pollPOST /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:
| Field | Type | Description |
|---|---|---|
.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
| Field | Type | Description |
|---|---|---|
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 -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" }'
{
"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
| Field | Type | Description |
|---|---|---|
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. |
{
"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
| Field | Type | Description |
|---|---|---|
service_request_idrequired |
string | The id returned by create. |
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.
| Field | Type | Description |
|---|---|---|
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.
Field naming differs from the query response: webhooks use
payment_url (not payment_link) and expires_at
(not expired_at).
Event body
| Field | Type | Description |
|---|---|---|
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. |
{
"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
}