Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,427 changes: 4,427 additions & 0 deletions api-references/payments/upi-issuance.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions content/endpoints.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
"path": "umap",
"order": 7,
"visible_in_sidebar": true
},
{
"name": "UPI Issuance",
"path": "upi-issuance",
"order": 8,
"visible_in_sidebar": true
}
]
},
Expand Down
2 changes: 1 addition & 1 deletion content/menuItems.json

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions content/payments/upi-issuance/api-envelope.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
sidebar_title: The API envelope
page_title: UPI Issuance API envelope
order: 2
visible_in_sidebar: true
---

## The API envelope

Every request and response body is encrypted, keeping PII (`deviceId`, `mobile`, account details) out of plain text on the wire. The scheme is a standard hybrid encryption envelope.

### What travels where

- **PII in the body.** `deviceId` and `mobile` are in the encrypted body, never in headers or the URL.
- **`idempotencyKey` in a header.** It stays a plain header, outside the encrypted body.
- **Reads are POSTs.** Even pure reads (`binding-status`, `list-vpas`) are `POST` so their PII body can be encrypted.

<hr class="tertiary" />

### Request format

The TPAP / Issuing App encrypts the request body into an envelope object with three base64 fields:

<CodeBlockWithCopy language="json">
{`{
"ct": "<base64: AES-256-CBC(PKCS#7) of the JSON body, under a random session key + IV>",
"sk": "<base64: RSA-OAEP(SHA-1) wrap of the 32-byte session key, under Setu's public key>",
"iv": "<base64: the 16-byte AES IV>"
}`}
</CodeBlockWithCopy>

For each request, the TPAP / Issuing App generates a random **32-byte AES-256 key** and **16-byte IV**, encrypts the JSON body with **AES-256-CBC** and PKCS#7 padding, and wraps the session key with **RSA-OAEP (SHA-1)** under Setu's public key. The TPAP / Issuing App keeps the session key and IV to decrypt the response.

### Response format

The response is encrypted under the same session key and IV the TPAP / Issuing App generated:

<CodeBlockWithCopy language="json">
{`{
"ct": "<base64: AES-256-CBC(PKCS#7) of the JSON response>",
"oha": "<hex: SHA-256 of the plaintext response, for integrity>"
}`}
</CodeBlockWithCopy>

The TPAP / Issuing App decrypts `ct` with the session key and IV, then verifies `oha` matches the SHA-256 of the decrypted plaintext.

<Callout type="tip">
The wire suite is fixed: RSA-OAEP-SHA1 for the key wrap, AES-256-CBC with PKCS#7
for the body, SHA-256 for the response integrity hash.
</Callout>

<hr class="tertiary" />

### Reference implementation (Python)

<CodeBlockWithCopy language="python">
{`import os, json, base64, hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes, serialization
def _pad(b, k=16): n = k - len(b) % k; return b + bytes([n]) * n
def _unpad(b): return b[:-b[-1]]
def encrypt(pub_pem: str, body: dict):
pub = serialization.load_pem_public_key(pub_pem.encode())
key, iv = os.urandom(32), os.urandom(16)
sk = pub.encrypt(key, padding.OAEP(mgf=padding.MGF1(hashes.SHA1()),
algorithm=hashes.SHA1(), label=None))
enc = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
ct = enc.update(_pad(json.dumps(body).encode())) + enc.finalize()
env = {"ct": base64.b64encode(ct).decode(),
"sk": base64.b64encode(sk).decode(),
"iv": base64.b64encode(iv).decode()}
return env, key, iv
def decrypt_response(key, iv, enc: dict):
ct = base64.b64decode(enc["ct"])
dec = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
plain = _unpad(dec.update(ct) + dec.finalize())
assert enc["oha"] == hashlib.sha256(plain).hexdigest(), "integrity mismatch"
return json.loads(plain)`}
</CodeBlockWithCopy>

`encrypt()` returns the request envelope `env` — the `{ct, sk, iv}` object — along with the session `key` and `iv`. The TPAP / Issuing App sends `env` as the request body with `Content-Type: application/json`, and passes the same `key` and `iv` to `decrypt_response()` to read the response.

### Next

- **[User Onboarding](/payments/upi-issuance/onboarding)** — bind a device, create a VPA, and verify OTPs.
- **[Testing on QA env](/payments/upi-issuance/qa-testing)** — reproduce every scenario.

<WasPageHelpful />
24 changes: 24 additions & 0 deletions content/payments/upi-issuance/api-reference.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
sidebar_title: API reference
page_title: UPI Issuance API reference
order: 6
visible_in_sidebar: true
---

## API reference

The complete, spec-backed API reference (request and response schemas, every field, every error) is being finalised and will render here from the OpenAPI definition.

In the meantime, each operation is documented with its request, response, and error table under [API integration](/payments/upi-issuance/onboarding/api-integration):

- [Device binding](/payments/upi-issuance/onboarding/api-integration/device-binding) — generate binding token, `binding-status`
- [OTP verification](/payments/upi-issuance/onboarding/api-integration/user-otp) — `otp/request`, `otp/verify`
- [VPA management](/payments/upi-issuance/onboarding/api-integration/vpa-management) — `vpa/check`, `create-vpa`, `get-vpa`, `list-vpas`, `vpa/deregister`
- [Payee blocklist](/payments/upi-issuance/payee-blocklist) — `block-vpa`, `unblock-vpa`, `list-blocked-vpas`
- [Programs](/payments/upi-issuance/onboarding/api-integration/programs) — `POST /programs`, `PATCH /programs/{id}`

### Next

- **[User Onboarding](/payments/upi-issuance/onboarding)** — the onboarding flow and a step-by-step API walkthrough.

<WasPageHelpful />
49 changes: 49 additions & 0 deletions content/payments/upi-issuance/onboarding.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
sidebar_title: User Onboarding
page_title: UPI Issuance User Onboarding
order: 3
visible_in_sidebar: true
---

## User Onboarding

Onboarding gets a user ready to transact on UPI. It runs in three steps.

**1. Device binding.** The TPAP / Issuing App requests a binding token from Setu, which generates one and returns it with a Virtual Mobile Number (VMN). The token is sent as a silent SMS from the user's SIM to the VMN. The telecom then notifies Setu of the VMN, the sending mobile, and the SMS body, and Setu checks all three to bind the device to the user.

**2. VPA creation.** The TPAP / Issuing App creates a VPA — the user's UPI address — over the user's account.

**3. OTP verification.** The TPAP / Issuing App requests an OTP from Setu, Setu delivers it to the user's mobile by SMS, and the TPAP / Issuing App submits it back to Setu to activate the VPA. Today this is **required on Android** and **not needed on iOS**.

<img src="/upi-issuance/onboarding-flow.png" alt="New user onboarding and VPA creation: device binding, VPA creation, and the OTP that activates the new VPA (Android)" />

<p style={{ textAlign: "center", fontStyle: "italic", opacity: 0.75 }}>New user onboarding / VPA creation for an existing user</p>

Once onboarded, the user has an active VPA and can transact on UPI.

On a **device change**, only device binding is repeated — a device change does not require VPA creation. As at onboarding, an OTP is required on Android and not on iOS.

On **iOS**, a successful SIM binding moves the user status straight to `active`.

On **Android**, a successful SIM binding moves the user status to `device-bound`. Requesting an OTP then moves the user to `otp-pending`. A successful OTP verification moves the user status to `active`.

<img src="/upi-issuance/device-change-flow.png" alt="Device change: device binding on the new device, with an OTP on Android moving the user from device-bound to active and iOS activating directly, no VPA creation" />

<p style={{ textAlign: "center", fontStyle: "italic", opacity: 0.75 }}>Device change — OTP verification, no VPA creation</p>

<Callout type="tip">
NPCI is not involved in onboarding for PPI Issuing Apps: the account provider is
already set, and PPI accounts do not need a UPI PIN to be set up.
</Callout>

### In this section

- **[Onboarding states](/payments/upi-issuance/onboarding/onboarding-states)** — the user and VPA state machines, and what moves them.
- **[API integration](/payments/upi-issuance/onboarding/api-integration)** — a step-by-step walkthrough of each onboarding operation.

### Next

- **[Payee blocklist](/payments/upi-issuance/payee-blocklist)** — block, unblock, and list blocked payee VPAs.
- **[Testing on QA env](/payments/upi-issuance/qa-testing)** — reproduce every scenario.

<WasPageHelpful />
46 changes: 46 additions & 0 deletions content/payments/upi-issuance/onboarding/api-integration.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
sidebar_title: API integration
page_title: UPI Issuance API integration
order: 1
visible_in_sidebar: true
---

## API integration

A step-by-step walkthrough of each onboarding operation. All routes are under `/api/v1`, all body-carrying routes use [the envelope](/payments/upi-issuance/api-envelope), and `idempotencyKey` is always a header.

- **[Device binding](/payments/upi-issuance/onboarding/api-integration/device-binding)** — generate binding token, the silent SMS, and the `binding-status` poll.
- **[OTP verification](/payments/upi-issuance/onboarding/api-integration/user-otp)** — `otp/request` and `otp/verify` to activate a VPA on Android.
- **[VPA management](/payments/upi-issuance/onboarding/api-integration/vpa-management)** — check, create, fetch, list, and deregister VPAs.
- **[Programs](/payments/upi-issuance/onboarding/api-integration/programs)** — create and update program configuration.

<hr class="tertiary" />

<Callout type="tip">
Testing any of these? Every scenario is reproducible on the QA env — see
<a href="/payments/upi-issuance/qa-testing"> Testing on QA env</a>.
</Callout>

## Error responses

Every error, on every endpoint, returns the **same shape** — encrypted in the envelope like any response:

<CodeBlockWithCopy language="json">
{`{
"traceId": "01J...",
"code": "invalid-user-state",
"message": "A human-readable description of what went wrong."
}`}
</CodeBlockWithCopy>

- The **HTTP status is authoritative** — branch on it first (`4xx`/`5xx` = error).
- `code` is a **stable machine string** to switch on; `message` is descriptive and may change.
- `traceId` is on **every** response, success or error. Log it and quote it to Setu when raising an issue.

A `500` with `code: internal-error` means something failed on Setu's side; retry with the same `idempotencyKey`. Each operation's page lists the specific `4xx` codes it can return.

### Next

- **[Device binding](/payments/upi-issuance/onboarding/api-integration/device-binding)** — generate a binding token and prove the SIM.

<WasPageHelpful />
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
---
sidebar_title: Device binding
page_title: UPI Issuance device binding
order: 0
visible_in_sidebar: true
---

## Device binding

Device binding proves that the user's SIM, mobile number, and device belong together. It is the entry point on any device — a fresh install, a new phone, or a re-login.

<img src="/upi-issuance/device-binding.png" alt="Device binding sequence: binding token, silent SMS from the SIM to the VMN, and the binding-status poll to active" />

### 1. Generate a binding token

`POST /api/v1/onboarding/binding-token` (enveloped)

| Field | Type | Required | Notes |
| :--- | :--- | :--- | :--- |
| `deviceId` | string | yes | The user's device id. |
| `mobile` | string | yes | The user's mobile number, with country code (e.g. `919999999999`). |
| `os` | string | yes | `android` or `ios`. |
| `otpRequired` | boolean | yes | Whether a successful SIM binding fully activates the user. `false` — the user goes straight to `active`. `true` — the user stops at `device-bound` until an [OTP](/payments/upi-issuance/onboarding/api-integration/user-otp) is verified. |

<br />

`idempotencyKey` is an optional request header.

**When OTP verification happens is controlled by the TPAP / Issuing App, and Setu respects it.** There are two options:

- To verify OTP **after SIM binding and before VPA creation**, generate the binding token with `otpRequired: true`. The user stays at `device-bound` until an OTP is verified. This is the **device change** case, where no new VPA is created.
- To verify OTP **after VPA creation**, generate the binding token with `otpRequired: false` and set `otpRequired: true` on [`create-vpa`](/payments/upi-issuance/onboarding/api-integration/vpa-management) instead. The new VPA stays `pending-verification` until an OTP is verified. This is the **new onboarding / new VPA** case.

##### Sample request

<CodeBlockWithCopy language="json">
{`{
"deviceId": "device-abc-123",
"mobile": "919999999999",
"os": "android",
"otpRequired": false
}`}
</CodeBlockWithCopy>

##### Success response — `200`

| Field | Type | Notes |
| :--- | :--- | :--- |
| `traceId` | string | Trace handle for this call (ULID). |
| `vmn` | string | The Virtual Mobile Number to send the SMS to. |
| `smsBody` | string | The ready-to-send silent-SMS body. The token is embedded here and is never returned separately. |

<CodeBlockWithCopy language="json">
{`{
"traceId": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"vmn": "919900000001",
"smsBody": "VERIFY SETqxf8aemkxxdrhbifd4n4zldq7guvxb2k"
}`}
</CodeBlockWithCopy>

##### Errors

| Status | Code | When |
| :--- | :--- | :--- |
| 400 | `invalid-request` | A required field is missing (`deviceId`, `mobile`, `os`, `otpRequired`) or the request body is malformed. |
| 429 | `device-binding-cap-exceeded` | Too many binding attempts for this device today. |
| 429 | `mobile-binding-cap-exceeded` | Too many binding attempts for this mobile today. |
| 500 | `internal-error` | Something went wrong on Setu's side. Retry with the same `idempotencyKey`. |

### 2. Send the silent SMS

`vmn` is a **Virtual Mobile Number** — a receive-only number that the binding SMS is sent to. The TPAP / Issuing App sends `smsBody` as a silent SMS from the user's SIM to `vmn`. The telecom provider then calls a webhook to Setu, indicating the VMN that received the SMS, the mobile it was sent from, and the SMS body. Setu checks all three — the SMS body must carry a token for a live binding, the VMN must be the one that binding was issued for, and the sending mobile must match the claimed `mobile`. When all three match, the SIM is proven and the binding advances. The TPAP / Issuing App does not call anything for this step.

The binding token is short-lived — it expires **45 seconds** after it is generated. The silent SMS (and the telecom's webhook back to Setu) must complete within that window; if it lapses, generate a fresh binding token.

<Callout type="tip">
On the QA env there is no telecom. Drive the outcome with a
<code>sim.bind-*</code> directive in the <code>idempotencyKey</code> of the
generate binding token call — see
<a href="/payments/upi-issuance/qa-testing"> Testing on QA env</a>.
</Callout>

### 3. Poll the binding status

`POST /api/v1/onboarding/binding-status` (enveloped)

| Field | Type | Required | Notes |
| :--- | :--- | :--- | :--- |
| `deviceId` | string | yes | The user's device id. |
| `mobile` | string | yes | The user's mobile number, with country code (e.g. `919999999999`). |

<br />

`idempotencyKey` is an optional request header.

##### Sample request

<CodeBlockWithCopy language="json">
{`{
"deviceId": "device-abc-123",
"mobile": "919999999999"
}`}
</CodeBlockWithCopy>

##### Success response — `200`

| Field | Type | Notes |
| :--- | :--- | :--- |
| `traceId` | string | Trace handle for this call (ULID). |
| `user` | object | The user in its current state (see [The user object](#the-user-object)). |

<CodeBlockWithCopy language="json">
{`{
"traceId": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"user": {
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"status": "active",
"deviceId": "device-abc-123",
"mobile": "919999999999"
}
}`}
</CodeBlockWithCopy>

Poll until the status settles. With `otpRequired: false` a successful SIM binding lands the user on `active`, and the TPAP / Issuing App can [create a VPA](/payments/upi-issuance/onboarding/api-integration/vpa-management). With `otpRequired: true` the user settles at `device-bound` and needs an [OTP](/payments/upi-issuance/onboarding/api-integration/user-otp) to reach `active` — the OTP is valid for **60 seconds** after it is requested.

##### Errors

| Status | Code | When |
| :--- | :--- | :--- |
| 400 | `invalid-request` | A required field is missing (`deviceId`, `mobile`) or the request body is malformed. |
| 404 | `binding-not-found` | No binding for this device — polled before generating a binding token, or from a device that is not the bound one. |
| 500 | `internal-error` | Something went wrong on Setu's side. |

### The user object

The [binding status](#3-poll-the-binding-status) and a device-change [OTP verification](/payments/upi-issuance/onboarding/api-integration/user-otp) return the user in this shape:

| Field | Type | Notes |
| :--- | :--- | :--- |
| `id` | string | The user's id (ULID). |
| `status` | string | `binding-pending`, `device-bound`, `otp-pending`, or `active`. |
| `deviceId` | string | The user's device id. |
| `mobile` | string | The user's mobile number, with country code (e.g. `919999999999`). |

<hr class="primary" />

### On a device change

When the user moves to a new phone or re-installs, only device binding is repeated — the three steps above, against the new `deviceId`. A device change does not require VPA creation. The OTP is OS-driven, just as at onboarding. On **iOS** (`otpRequired: false`) a successful SIM binding moves the user to `active`. On **Android** (`otpRequired: true`) a successful SIM binding moves the user to `device-bound`, and an [OTP](/payments/upi-issuance/onboarding/api-integration/user-otp) then moves the user to `active` before the account is usable on the new device.

### Next

- **[OTP verification](/payments/upi-issuance/onboarding/api-integration/user-otp)** — request and verify OTPs.
- **[VPA management](/payments/upi-issuance/onboarding/api-integration/vpa-management)** — create and manage VPAs.

<WasPageHelpful />
Loading