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
99 changes: 94 additions & 5 deletions api-references/payments/upi-issuance.json

Large diffs are not rendered by default.

36 changes: 29 additions & 7 deletions content/payments/upi-issuance/api-envelope.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,41 @@ Every request and response body is encrypted, keeping PII (`deviceId`, `mobile`,
- **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.
- **Your credentials sign the request.** Setu issues you a `clientId` and a `clientSecret`. Every request carries your `clientId` and a `sig` (an HMAC of the request payload keyed by your secret) so we can authenticate that the call is really from you — see [Signing the request](#signing-the-request).

<hr class="tertiary" />

### Request format

The TPAP / Issuing App encrypts the request body into an envelope object with three base64 fields:
The TPAP / Issuing App encrypts the request body into an envelope object. Alongside the three base64 encryption fields, it carries your `clientId` and the request signature `sig`:

<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>"
"iv": "<base64: the 16-byte AES IV>",
"clientId": "<your client id, issued by Setu>",
"sig": "<base64: HMAC-SHA256(clientSecret, the plaintext JSON body)>"
}`}
</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.

<hr class="tertiary" />

### Signing the request

Setu issues you two credentials: a **`clientId`** and a **`clientSecret`**. On every request you:

1. Set `clientId` to the value Setu gave you.
2. Compute `sig` = **base64( HMAC-SHA256( clientSecret, plaintext JSON body ) )** — sign the *same* JSON string you encrypt, **before** encryption, using your `clientSecret` as the HMAC key.

Setu verifies the signature after decrypting your request. A missing signature, a signature that does not verify, or a `clientId` that isn't yours is rejected with **`401`** and the error code **`invalid-signature`**.

<Callout type="warning">
Keep your `clientSecret` secret: it lives only on your backend, never in a mobile app or browser. Sign on the server, then send the envelope. If you rotate the secret with Setu, the previous secret keeps working through the overlap window so you can cut over without downtime.
</Callout>

### Response format

The response is encrypted under the same session key and IV the TPAP / Issuing App generated:
Expand All @@ -54,22 +72,26 @@ The TPAP / Issuing App decrypts `ct` with the session key and IV, then verifies
### Reference implementation (Python)

<CodeBlockWithCopy language="python">
{`import os, json, base64, hashlib
{`import os, json, base64, hashlib, hmac
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):
def encrypt(pub_pem: str, client_id: str, client_secret: str, body: dict):
pub = serialization.load_pem_public_key(pub_pem.encode())
key, iv = os.urandom(32), os.urandom(16)
plaintext = json.dumps(body).encode()
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()
ct = enc.update(_pad(plaintext)) + enc.finalize()
sig = hmac.new(client_secret.encode(), plaintext, hashlib.sha256).digest()
env = {"ct": base64.b64encode(ct).decode(),
"sk": base64.b64encode(sk).decode(),
"iv": base64.b64encode(iv).decode()}
"iv": base64.b64encode(iv).decode(),
"clientId": client_id,
"sig": base64.b64encode(sig).decode()}
return env, key, iv
def decrypt_response(key, iv, enc: dict):
ct = base64.b64decode(enc["ct"])
Expand All @@ -79,7 +101,7 @@ def decrypt_response(key, iv, enc: dict):
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.
`encrypt()` signs the plaintext body with your `clientSecret`, then returns the request envelope `env` — the `{ct, sk, iv, clientId, sig}` 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. The signature is computed over the exact `json.dumps(body)` bytes that are encrypted, so encrypt and sign use the *same* serialization.

### Next

Expand Down
4 changes: 2 additions & 2 deletions content/payments/upi-issuance/onboarding.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Onboarding gets a user ready to transact on UPI. It runs in three steps.

**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)" />
<img src="https://docs-assets.setu.co/latest/payments/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>

Expand All @@ -27,7 +27,7 @@ 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" />
<img src="https://docs-assets.setu.co/latest/payments/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>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ visible_in_sidebar: true

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" />
<img src="https://docs-assets.setu.co/latest/payments/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`). |
| `deviceId` | string | yes | The user's device id. Non-empty, up to 128 characters. |
| `mobile` | string | yes | The user's mobile number, with country code — 12 digits, `^[0-9]{12}$` (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. |

Expand Down Expand Up @@ -62,7 +62,7 @@ Device binding proves that the user's SIM, mobile number, and device belong toge

| Status | Code | When |
| :--- | :--- | :--- |
| 400 | `invalid-request` | A required field is missing (`deviceId`, `mobile`, `os`, `otpRequired`) or the request body is malformed. |
| 400 | `invalid-request` | A required field is missing (`deviceId`, `mobile`, `os`, `otpRequired`), a field fails format validation (e.g. `mobile` is not 12 digits, `os` is not `android`/`ios`), 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`. |
Expand All @@ -86,12 +86,12 @@ The binding token is short-lived — it expires **45 seconds** after it is gener

| 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`). |
| `deviceId` | string | yes | The user's device id. Non-empty, up to 128 characters. |
| `mobile` | string | yes | The user's mobile number, with country code — 12 digits, `^[0-9]{12}$` (e.g. `919999999999`). |

<br />

`idempotencyKey` is an optional request header.
`idempotencyKey` is an optional request header (opaque, up to 128 characters).

##### Sample request

Expand Down Expand Up @@ -127,7 +127,7 @@ Poll until the status settles. With `otpRequired: false` a successful SIM bindin

| Status | Code | When |
| :--- | :--- | :--- |
| 400 | `invalid-request` | A required field is missing (`deviceId`, `mobile`) or the request body is malformed. |
| 400 | `invalid-request` | A required field is missing (`deviceId`, `mobile`), a field fails format validation (e.g. `mobile` is not 12 digits), 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. |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ Both endpoints return the program under `program` in this shape:

| Field | Type | Required | Notes |
| :--- | :--- | :--- | :--- |
| `name` | string | yes | The program's display name. |
| `code` | string | yes | The program's short code — its channel identifier. |
| `name` | string | yes | The program's display name. 1–99 characters; letters, digits, spaces and `. ' & -`. |
| `code` | string | yes | The program's short code — its channel identifier. 1–32 characters; letters, digits, `_` and `-`. |
| `isCreditAllowed` | boolean | no | May wallets on this program receive incoming UPI? Defaults `true`. |
| `isDebitAllowed` | boolean | no | May wallets on this program pay outgoing UPI? Defaults `true`. |
| `amountLimit` | integer | no | Per-transaction cap, in paise. Omit for no cap. |
| `amountLimit` | integer | no | Per-transaction cap, in paise. Omit for no cap. Integer `≥ 0`. |

##### Sample request

Expand Down Expand Up @@ -77,8 +77,7 @@ Both endpoints return the program under `program` in this shape:

| Status | Code | When |
| :--- | :--- | :--- |
| 400 | `invalid-request` | The request body is malformed. |
| 400 | `missing-parameter` | `name` or `code` is missing. |
| 400 | `invalid-request` | `name` or `code` is missing or fails format validation, or the request body is malformed. |
| 500 | `internal-error` | Something went wrong on Setu's side. |

<hr class="tertiary" />
Expand All @@ -89,11 +88,11 @@ Both endpoints return the program under `program` in this shape:

| Field | Type | Required | Notes |
| :--- | :--- | :--- | :--- |
| `name` | string | no | The program's display name. |
| `code` | string | no | The program's short code. |
| `name` | string | no | The program's display name. 1–99 characters; letters, digits, spaces and `. ' & -`. |
| `code` | string | no | The program's short code. 1–32 characters; letters, digits, `_` and `-`. |
| `isCreditAllowed` | boolean | no | May wallets on this program receive incoming UPI? |
| `isDebitAllowed` | boolean | no | May wallets on this program pay outgoing UPI? |
| `amountLimit` | integer | no | Per-transaction cap, in paise. |
| `amountLimit` | integer | no | Per-transaction cap, in paise. Integer `≥ 0`. |
| `status` | string | no | `active` or `inactive`. An `inactive` program is rejected at the onboarding gate (`400 invalid-program`). |

##### Sample request
Expand Down Expand Up @@ -132,9 +131,8 @@ For example, deactivate a program:

| Status | Code | When |
| :--- | :--- | :--- |
| 400 | `invalid-request` | The request body is malformed. |
| 400 | `missing-parameter` | `programId` is missing. |
| 404 | `program-not-found` | No program with that `programId`. |
| 400 | `invalid-request` | `programId` is not a valid ULID, a body field fails format validation, or the request body is malformed. |
| 404 | `program-not-found` | No program with that (well-formed) `programId`. |
| 500 | `internal-error` | Something went wrong on Setu's side. |

### Next
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ The OTP for a new VPA never changes the user's status. The OTP for a device chan

A newly created VPA can require an OTP before it can transact. This is **OS-dependent**: on **Android** the OTP is required and the VPA is created `pending-verification`, on **iOS** it is not and the VPA is created `active`. The TPAP / Issuing App signals it by setting `otpRequired` on [`create-vpa`](/payments/upi-issuance/onboarding/api-integration/vpa-management) to match the device OS.

<img src="/upi-issuance/vpa-activation.png" alt="New VPA activation: on Android create-vpa creates the VPA pending-verification and an OTP promotes it to active, on iOS it is created active directly" />
<img src="https://docs-assets.setu.co/latest/payments/upi-issuance/vpa-activation.png" alt="New VPA activation: on Android create-vpa creates the VPA pending-verification and an OTP promotes it to active, on iOS it is created active directly" />

## Device change

When `otpRequired: true` is sent on the [generate binding token](/payments/upi-issuance/onboarding/api-integration/device-binding) call, a successful SIM binding leaves the user at `device-bound` rather than `active`. Requesting the OTP moves the user to `otp-pending`, and a successful verification moves the user to `active`, letting them transact from the new device. This confirms the user is present on the new device.

This is **OS-dependent** — `otpRequired` is `true` on **Android** (the user stops at `device-bound`) and `false` on **iOS** (a successful SIM binding activates the user directly).

<img src="/upi-issuance/user-activation.png" alt="Device change: on Android otpRequired true leaves the user device-bound and an OTP promotes them to active, on iOS the SIM binding activates the user directly" />
<img src="https://docs-assets.setu.co/latest/payments/upi-issuance/user-activation.png" alt="Device change: on Android otpRequired true leaves the user device-bound and an OTP promotes them to active, on iOS the SIM binding activates the user directly" />

<hr class="primary" />

Expand All @@ -42,13 +42,13 @@ This is **OS-dependent** — `otpRequired` is `true` on **Android** (the user st

| 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`). |
| `vpa` | string | conditional | Include it for the **new onboarding / new VPA** case (the VPA being activated). Omit it for the **device change** case. |
| `deviceId` | string | yes | The user's device id. Non-empty, up to 128 characters. |
| `mobile` | string | yes | The user's mobile number, with country code — 12 digits, `^[0-9]{12}$` (e.g. `919999999999`). |
| `vpa` | string | conditional | Include it for the **new onboarding / new VPA** case (the VPA being activated). Omit it for the **device change** case. When present: `prefix@handle`, max 255 characters — `^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$`. |

<br />

`idempotencyKey` is an optional request header.
`idempotencyKey` is an optional request header (opaque, up to 128 characters).

##### Sample request

Expand Down Expand Up @@ -78,7 +78,7 @@ Omit `vpa` for the **device change** case.

| Status | Code | When |
| :--- | :--- | :--- |
| 400 | `invalid-request` | `deviceId` or `mobile` is missing, or the request body is malformed. |
| 400 | `invalid-request` | `deviceId` or `mobile` is missing or fails format validation (e.g. `mobile` not 12 digits, a malformed `vpa`), or the request body is malformed. |
| 401 | `device-not-linked` | This device is not bound for this user. |
| 404 | `user-not-found` | No user for this mobile. |
| 404 | `vpa-not-found` | A `vpa` was sent but the user does not own a VPA with that string. |
Expand All @@ -94,10 +94,10 @@ Omit `vpa` for the **device change** case.

| 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`). |
| `otp` | string | yes | The digits the user entered. |
| `vpa` | string | conditional | Include it for the **new onboarding / new VPA** case (the VPA being activated). Omit it for the **device change** case. |
| `deviceId` | string | yes | The user's device id. Non-empty, up to 128 characters. |
| `mobile` | string | yes | The user's mobile number, with country code — 12 digits, `^[0-9]{12}$` (e.g. `919999999999`). |
| `otp` | string | yes | The 6-digit code the user entered — `^[0-9]{6}$`. |
| `vpa` | string | conditional | Include it for the **new onboarding / new VPA** case (the VPA being activated). Omit it for the **device change** case. When present: `prefix@handle`, max 255 characters — `^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$`. |

<br />

Expand Down Expand Up @@ -160,8 +160,7 @@ The **device change** case returns the user instead:

| Status | Code | When |
| :--- | :--- | :--- |
| 400 | `invalid-request` | `deviceId` or `mobile` is missing, or the request body is malformed. |
| 400 | `missing-parameter` | `otp` is missing. |
| 400 | `invalid-request` | `deviceId`, `mobile`, or `otp` is missing or fails format validation (`otp` must be 6 digits, `mobile` 12 digits), or the request body is malformed. |
| 401 | `device-not-linked` | This device is not bound for this user. |
| 401 | `invalid-otp` | The OTP is incorrect. The attempt is consumed. |
| 404 | `user-not-found` | No user for this mobile. |
Expand Down
Loading
Loading