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://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://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://upistation.com/pay/<service_request_id>. In sandbox it shows the order and resolves to the mocked outcome automatically (no real scan needed).
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 | The bank / payment system you collect through. Must name a system you have actually connected — see Supported payment systems for the accepted values (e.g. PAYTM, UTKARSH, HDFC). It then selects that system's merchant account automatically — you only need merchant_account_id if you have more than one account on the same system. Sending a value for a system you have not connected returns 409 no_merchant_accounts. |
merchant_account_id |
string | Which connected merchant account to collect into. Optional when only one account matches payment_system; required (400 merchant_account_required) when you have multiple accounts on that system. Account ids are listed on your merchant accounts page. |
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 and its hosted link expire. Defaults to 30 when omitted. Once the window elapses an unpaid request becomes EXPIRED, a request.status.changed webhook fires, and the link can no longer be paid. |
Supported payment systems
UPI Station supports more than one bank / aggregator. Set
payment_system to the value for the one you have connected. Values are
case-insensitive and ignore punctuation (so utkarsh-vyapar also works).
| Payment system | payment_system value(s) |
|---|---|
| Paytm | PAYTM |
| Utkarsh Vyapar | UTKARSH, UTKARSH_VYAPAR |
| HDFC SmartHub Vyapar | HDFC, HDFC_VYAPAR, SMARTHUB, SMARTHUB_VYAPAR |
The example below uses PAYTM; substitute the value for your
connected system. Sending a value for a system you have not connected returns
409 no_merchant_accounts.
curl -sS -X POST https://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, payer_name, payment_at, rrn) once PAID. |
payment_link |
string (uri) | Hosted page to send the customer to. |
intent_url |
string | null | Best-effort. Raw upi://pay?… intent for driving your own checkout. Most accounts can't use it — many merchant VPAs (e.g. Paytm static-QR) decline a direct UPI intent and accept only a scanned QR. If it works for your account, use it; otherwise rely on payment_link. |
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": "UPIS260530040214SAMPLE",
"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://upistation.com/pay/UPIS260530040214SAMPLE",
"intent_url": "upi://pay?pa=merchant@bank&pn=Merchant&am=100.00&cu=INR&tr=UPIS260530040214SAMPLE&tn=UPIS260530040214SAMPLE",
"status_updated_at": "2026-05-30T04:02:14.463Z",
"expired_at": null,
"notes": null
}
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://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":"UPIS260530040214SAMPLE"}'
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.
localhost and private addresses (127.0.0.1, 10.x,
192.168.x, etc.) can never be reached and are rejected. For local development,
expose your server with a tunnel (e.g. ngrok or cloudflared) and use that public URL. You
can watch delivery status (and any errors) under For Developers → Webhooks → Recent deliveries.
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. |
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": "UPIS260530040214SAMPLE",
"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",
"payer_name": "Asha Verma",
"payment_at": "2026-05-30T04:05:11.000Z",
"rrn": "401512345678"
},
"payment_url": "https://upistation.com/pay/UPIS260530040214SAMPLE",
"status_updated_at": "2026-05-30T04:05:11.000Z",
"expires_at": null,
"notes": null
}