From d88379aa7ebd7fb35b68c9fa4e194369d2a7bd66 Mon Sep 17 00:00:00 2001 From: Anindya Pandey Date: Fri, 17 Jul 2026 16:44:31 +0530 Subject: [PATCH 1/3] docs(UPIIS-47): CDN image refs + tighten API field validation docs - swap 7 local img srcs to docs-assets CDN - add field constraints/regex to user-otp & device-binding tables Co-Authored-By: Claude Opus 4.8 --- content/payments/upi-issuance/onboarding.mdx | 4 +-- .../api-integration/device-binding.mdx | 16 ++++++------ .../onboarding/api-integration/user-otp.mdx | 25 +++++++++---------- .../onboarding/onboarding-states.mdx | 4 +-- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/content/payments/upi-issuance/onboarding.mdx b/content/payments/upi-issuance/onboarding.mdx index 13e9ae57..88a31036 100644 --- a/content/payments/upi-issuance/onboarding.mdx +++ b/content/payments/upi-issuance/onboarding.mdx @@ -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**. -New user onboarding and VPA creation: device binding, VPA creation, and the OTP that activates the new VPA (Android) +New user onboarding and VPA creation: device binding, VPA creation, and the OTP that activates the new VPA (Android)

New user onboarding / VPA creation for an existing user

@@ -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`. -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 +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

Device change — OTP verification, no VPA creation

diff --git a/content/payments/upi-issuance/onboarding/api-integration/device-binding.mdx b/content/payments/upi-issuance/onboarding/api-integration/device-binding.mdx index 409256c4..4893a482 100644 --- a/content/payments/upi-issuance/onboarding/api-integration/device-binding.mdx +++ b/content/payments/upi-issuance/onboarding/api-integration/device-binding.mdx @@ -9,7 +9,7 @@ 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. -Device binding sequence: binding token, silent SMS from the SIM to the VMN, and the binding-status poll to active +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 @@ -17,8 +17,8 @@ Device binding proves that the user's SIM, mobile number, and device belong toge | 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. | @@ -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`. | @@ -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`). |
-`idempotencyKey` is an optional request header. +`idempotencyKey` is an optional request header (opaque, up to 128 characters). ##### Sample request @@ -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. | diff --git a/content/payments/upi-issuance/onboarding/api-integration/user-otp.mdx b/content/payments/upi-issuance/onboarding/api-integration/user-otp.mdx index 99948861..90328ded 100644 --- a/content/payments/upi-issuance/onboarding/api-integration/user-otp.mdx +++ b/content/payments/upi-issuance/onboarding/api-integration/user-otp.mdx @@ -24,7 +24,7 @@ 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. -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 +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 @@ -32,7 +32,7 @@ When `otpRequired: true` is sent on the [generate binding token](/payments/upi-i 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). -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 +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
@@ -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.-]+)$`. |
-`idempotencyKey` is an optional request header. +`idempotencyKey` is an optional request header (opaque, up to 128 characters). ##### Sample request @@ -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. | @@ -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.-]+)$`. |
@@ -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. | diff --git a/content/payments/upi-issuance/onboarding/onboarding-states.mdx b/content/payments/upi-issuance/onboarding/onboarding-states.mdx index 35fd998b..a33e82c1 100644 --- a/content/payments/upi-issuance/onboarding/onboarding-states.mdx +++ b/content/payments/upi-issuance/onboarding/onboarding-states.mdx @@ -44,13 +44,13 @@ OTP verification is **required for Android** and not needed on iOS. **When** it OTP verification is performed after VPA creation and blocks VPA activation, preventing any transactions on the new VPA until OTP verification is done. -New onboarding: on Android create-vpa creates the VPA pending-verification and an OTP promotes it to active, on iOS the VPA is created active directly +New onboarding: on Android create-vpa creates the VPA pending-verification and an OTP promotes it to active, on iOS the VPA is created active directly #### Device change OTP verification is performed at the user level, right after device binding on the new device. A successful SIM binding leaves the user at `device-bound`, and OTP verification moves the user to `active`. No new VPA is created. -Device change: on Android binding on the new device leaves the user device-bound and an OTP promotes them to active, on iOS the SIM binding activates the user directly +Device change: on Android binding on the new device leaves the user device-bound and an OTP promotes them to active, on iOS the SIM binding activates the user directly ### Next From 6d7289249998751326ec77b3bd49a801264cacd0 Mon Sep 17 00:00:00 2001 From: Anindya Pandey Date: Sat, 18 Jul 2026 23:08:36 +0530 Subject: [PATCH 2/3] docs(UPIIS-47): document inline validation rules + vpa-account-mismatch Add the create-VPA `vpa-account-mismatch` (409) error and a note that re-linking a VPA to a different account requires an explicit deregister first (Setu never silently re-points an active VPA at a different account). Finish documenting the inline field-validation rules (formats, lengths, patterns, enums) across the onboarding, program and payee-blocklist API references, mirrored in the OpenAPI spec. --- api-references/payments/upi-issuance.json | 99 ++++++++++++++++++- .../onboarding/api-integration/programs.mdx | 20 ++-- .../api-integration/vpa-management.mdx | 61 ++++++------ .../payments/upi-issuance/payee-blocklist.mdx | 22 ++--- 4 files changed, 143 insertions(+), 59 deletions(-) diff --git a/api-references/payments/upi-issuance.json b/api-references/payments/upi-issuance.json index c2349072..7017f460 100644 --- a/api-references/payments/upi-issuance.json +++ b/api-references/payments/upi-issuance.json @@ -27,6 +27,7 @@ "allowEmptyValue": true, "schema": { "type": "string", + "maxLength": 128, "description": "Optional client-generated idempotency key for safe retries.", "example": "req-8f3a2b1c" }, @@ -358,6 +359,7 @@ "allowEmptyValue": true, "schema": { "type": "string", + "maxLength": 128, "description": "Optional client-generated idempotency key for safe retries.", "example": "req-8f3a2b1c" }, @@ -641,6 +643,7 @@ "allowEmptyValue": true, "schema": { "type": "string", + "maxLength": 128, "description": "Optional client-generated idempotency key for safe retries.", "example": "req-8f3a2b1c" }, @@ -944,6 +947,7 @@ "allowEmptyValue": true, "schema": { "type": "string", + "maxLength": 128, "description": "Optional client-generated idempotency key for safe retries.", "example": "req-8f3a2b1c" }, @@ -1195,6 +1199,7 @@ "allowEmptyValue": true, "schema": { "type": "string", + "maxLength": 128, "description": "Optional client-generated idempotency key for safe retries.", "example": "req-8f3a2b1c" }, @@ -1385,7 +1390,8 @@ "description": "Machine-readable error code.", "enum": [ "invalid-user-state", - "vpa-taken" + "vpa-taken", + "vpa-account-mismatch" ], "example": "invalid-user-state" }, @@ -1464,6 +1470,7 @@ "allowEmptyValue": true, "schema": { "type": "string", + "maxLength": 128, "description": "Optional client-generated idempotency key for safe retries.", "example": "req-8f3a2b1c" }, @@ -1724,6 +1731,7 @@ "allowEmptyValue": true, "schema": { "type": "string", + "maxLength": 128, "description": "Optional client-generated idempotency key for safe retries.", "example": "req-8f3a2b1c" }, @@ -1996,6 +2004,7 @@ "allowEmptyValue": true, "schema": { "type": "string", + "maxLength": 128, "description": "Optional client-generated idempotency key for safe retries.", "example": "req-8f3a2b1c" }, @@ -2384,6 +2393,7 @@ "required": true, "schema": { "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", "description": "The program to update (ULID).", "example": "01ARZ3NDEKTSV4RRFFQ69G5FAV" }, @@ -2565,6 +2575,7 @@ "allowEmptyValue": true, "schema": { "type": "string", + "maxLength": 128, "description": "Optional client-generated idempotency key for safe retries.", "example": "req-8f3a2b1c" }, @@ -2818,6 +2829,7 @@ "allowEmptyValue": true, "schema": { "type": "string", + "maxLength": 128, "description": "Optional client-generated idempotency key for safe retries.", "example": "req-8f3a2b1c" }, @@ -3072,6 +3084,7 @@ "allowEmptyValue": true, "schema": { "type": "string", + "maxLength": 128, "description": "Optional client-generated idempotency key for safe retries.", "example": "req-8f3a2b1c" }, @@ -3426,16 +3439,22 @@ "properties": { "deviceId": { "type": "string", + "minLength": 1, + "maxLength": 128, "description": "The user's device id.", "example": "device-abc-123" }, "mobile": { "type": "string", + "pattern": "^[0-9]{12}$", "description": "The user's mobile number, with country code (e.g. 919999999999).", "example": "919999999999" }, "payeeVpa": { "type": "string", + "pattern": "^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$", + "minLength": 1, + "maxLength": 255, "description": "The external payee VPA to block or unblock (prefix@handle; any handle).", "example": "merchant@otherbank" } @@ -3574,21 +3593,28 @@ "properties": { "deviceId": { "type": "string", + "minLength": 1, + "maxLength": 128, "description": "The user's device id.", "example": "device-abc-123" }, "mobile": { "type": "string", + "pattern": "^[0-9]{12}$", "description": "The user's mobile number, with country code (e.g. 919999999999).", "example": "919999999999" }, "programId": { "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", "description": "Optional program id (ULID). Validated when supplied.", "example": "01ARZ3NDEKTSV4RRFFQ69G5FAV" }, "vpa": { "type": "string", + "pattern": "^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$", + "minLength": 1, + "maxLength": 255, "description": "The full VPA to check (prefix@handle, e.g. 919999999999-alice@setu). Its prefix must start with the user's mobile number.", "example": "919999999999-alice@setu" } @@ -3610,12 +3636,16 @@ "properties": { "amountLimit": { "type": "integer", + "format": "int64", + "minimum": 0, "description": "Per-transaction cap, in paise. Omit for no cap.", - "example": 500000, - "format": "int64" + "example": 500000 }, "code": { "type": "string", + "pattern": "^[A-Za-z0-9_-]+$", + "minLength": 1, + "maxLength": 32, "description": "The program's short code — its channel identifier.", "example": "ACME" }, @@ -3631,6 +3661,9 @@ }, "name": { "type": "string", + "pattern": "^[A-Za-z0-9 .'&-]+$", + "minLength": 1, + "maxLength": 99, "description": "The program's display name.", "example": "Acme Wallet" } @@ -3652,16 +3685,21 @@ "properties": { "accountName": { "type": "string", + "pattern": "^[A-Za-z0-9 .'&-]+$", + "minLength": 1, + "maxLength": 99, "description": "The account-holder name.", "example": "Alice Doe" }, "accountNo": { "type": "string", + "pattern": "^[A-Z0-9]{6,18}$", "description": "The wallet / pool account number.", "example": "99887766554433" }, "accountProviderId": { "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", "description": "The account-provider id. Optional when a TPAP default is configured.", "example": "01ARZ3NDEKTSV4RRFFQ69G5FAV" }, @@ -3677,16 +3715,20 @@ }, "deviceId": { "type": "string", + "minLength": 1, + "maxLength": 128, "description": "The user's device id.", "example": "device-abc-123" }, "ifsc": { "type": "string", + "pattern": "^[A-Z]{4}0[A-Z0-9]{6}$", "description": "The IFSC of the account. Optional when a TPAP default is configured.", "example": "SETU0000001" }, "mobile": { "type": "string", + "pattern": "^[0-9]{12}$", "description": "The user's mobile number, with country code (e.g. 919999999999).", "example": "919999999999" }, @@ -3697,11 +3739,15 @@ }, "programId": { "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", "description": "Optional program id (ULID) — the program this VPA belongs to. Validated when supplied.", "example": "01ARZ3NDEKTSV4RRFFQ69G5FAV" }, "vpa": { "type": "string", + "pattern": "^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$", + "minLength": 1, + "maxLength": 255, "description": "The full VPA to create (prefix@handle, e.g. 919999999999-alice@setu). Its prefix must start with the user's mobile number.", "example": "919999999999-alice@setu" } @@ -3764,16 +3810,22 @@ "properties": { "deviceId": { "type": "string", + "minLength": 1, + "maxLength": 128, "description": "The user's device id.", "example": "device-abc-123" }, "mobile": { "type": "string", + "pattern": "^[0-9]{12}$", "description": "The user's mobile number, with country code (e.g. 919999999999).", "example": "919999999999" }, "vpa": { "type": "string", + "pattern": "^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$", + "minLength": 1, + "maxLength": 255, "description": "The full VPA to fetch (prefix@handle, e.g. 919999999999-alice@setu). Its prefix must start with the user's mobile number.", "example": "919999999999-alice@setu" } @@ -3799,6 +3851,8 @@ }, "deviceId": { "type": "string", + "minLength": 1, + "maxLength": 128, "description": "The user's device id.", "example": "device-abc-123" }, @@ -3810,6 +3864,7 @@ }, "mobile": { "type": "string", + "pattern": "^[0-9]{12}$", "description": "The user's mobile number, with country code (e.g. 919999999999).", "example": "919999999999" } @@ -3913,11 +3968,14 @@ "properties": { "deviceId": { "type": "string", + "minLength": 1, + "maxLength": 128, "description": "The user's device id.", "example": "device-abc-123" }, "mobile": { "type": "string", + "pattern": "^[0-9]{12}$", "description": "The user's mobile number, with country code (e.g. 919999999999).", "example": "919999999999" } @@ -4022,16 +4080,23 @@ "properties": { "deviceId": { "type": "string", + "minLength": 1, + "maxLength": 128, "description": "The user's device id.", "example": "device-abc-123" }, "mobile": { "type": "string", + "pattern": "^[0-9]{12}$", "description": "The user's mobile number, with country code (e.g. 919999999999).", "example": "919999999999" }, "os": { "type": "string", + "enum": [ + "android", + "ios" + ], "description": "The device OS: android or ios.", "example": "android" }, @@ -4059,16 +4124,22 @@ "properties": { "deviceId": { "type": "string", + "minLength": 1, + "maxLength": 128, "description": "The user's device id.", "example": "device-abc-123" }, "mobile": { "type": "string", + "pattern": "^[0-9]{12}$", "description": "The user's mobile number, with country code (e.g. 919999999999).", "example": "919999999999" }, "vpa": { "type": "string", + "pattern": "^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$", + "minLength": 1, + "maxLength": 255, "description": "Include the VPA to verify a new VPA (new onboarding / new VPA); omit for a device-change OTP (SIM re-binding).", "example": "919999999999-alice@setu" } @@ -4088,12 +4159,16 @@ "properties": { "amountLimit": { "type": "integer", + "format": "int64", + "minimum": 0, "description": "Per-transaction cap, in paise. Omit for no cap.", - "example": 500000, - "format": "int64" + "example": 500000 }, "code": { "type": "string", + "pattern": "^[A-Za-z0-9_-]+$", + "minLength": 1, + "maxLength": 32, "description": "The program's short code.", "example": "ACME" }, @@ -4109,11 +4184,18 @@ }, "name": { "type": "string", + "pattern": "^[A-Za-z0-9 .'&-]+$", + "minLength": 1, + "maxLength": 99, "description": "The program's display name.", "example": "Acme Wallet" }, "status": { "type": "string", + "enum": [ + "active", + "inactive" + ], "description": "active or inactive.", "example": "inactive" } @@ -4167,21 +4249,28 @@ "properties": { "deviceId": { "type": "string", + "minLength": 1, + "maxLength": 128, "description": "The user's device id.", "example": "device-abc-123" }, "mobile": { "type": "string", + "pattern": "^[0-9]{12}$", "description": "The user's mobile number, with country code (e.g. 919999999999).", "example": "919999999999" }, "otp": { "type": "string", + "pattern": "^[0-9]{6}$", "description": "The OTP the user entered.", "example": "123456" }, "vpa": { "type": "string", + "pattern": "^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$", + "minLength": 1, + "maxLength": 255, "description": "Include the VPA to verify a new VPA (new onboarding / new VPA); omit for a device-change OTP (SIM re-binding).", "example": "919999999999-alice@setu" } diff --git a/content/payments/upi-issuance/onboarding/api-integration/programs.mdx b/content/payments/upi-issuance/onboarding/api-integration/programs.mdx index da1c7d14..152d782e 100644 --- a/content/payments/upi-issuance/onboarding/api-integration/programs.mdx +++ b/content/payments/upi-issuance/onboarding/api-integration/programs.mdx @@ -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 @@ -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. |
@@ -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 @@ -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 diff --git a/content/payments/upi-issuance/onboarding/api-integration/vpa-management.mdx b/content/payments/upi-issuance/onboarding/api-integration/vpa-management.mdx index 8aee3003..c547a704 100644 --- a/content/payments/upi-issuance/onboarding/api-integration/vpa-management.mdx +++ b/content/payments/upi-issuance/onboarding/api-integration/vpa-management.mdx @@ -34,10 +34,10 @@ Once a user is `active`, the TPAP / Issuing App creates and manages their VPAs. | 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 | yes | The VPA to check — the full VPA `prefix@handle` (e.g. `919999999999-alice@setu`). Its VPA prefix must start with the subscriber's mobile. | -| `programId` | string | no | ULID. Validated for existence only. | +| `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 | yes | The VPA to check — the full VPA `prefix@handle` (e.g. `919999999999-alice@setu`), max 255 characters, `^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$`. Its VPA prefix must start with the subscriber's mobile. | +| `programId` | string | no | A 26-character ULID — `^[0-9A-HJKMNP-TV-Z]{26}$`. Validated for existence when supplied. | ##### Sample request @@ -69,9 +69,8 @@ Once a user is `active`, the TPAP / Issuing App creates and manages their VPAs. | Status | Code | When | | :--- | :--- | :--- | -| 400 | `invalid-request` | `deviceId` or `mobile` is missing, or the request body is malformed. | -| 400 | `missing-parameter` | `vpa` is missing. | -| 400 | `invalid-program` | Unknown or inactive `programId`. | +| 400 | `invalid-request` | `deviceId`, `mobile`, or `vpa` is missing or fails format validation, or the request body is malformed. | +| 400 | `invalid-program` | A well-formed `programId` that is unknown or inactive. | | 400 | `invalid-vpa` | The VPA prefix does not start with the subscriber's mobile number. | | 400 | `invalid-handle` | The VPA carries a handle that is not the one configured for this TPAP. | | 401 | `device-not-linked` | This device is not bound for this user. | @@ -87,20 +86,22 @@ Once a user is `active`, the TPAP / Issuing App creates and manages their VPAs. | 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 | yes | The VPA to create — the full VPA `prefix@handle` (e.g. `919999999999-alice@setu`). Its VPA prefix must start with the subscriber's mobile. | -| `accountNo` | string | yes | The wallet / pool account number. | -| `accountName` | string | yes | The account-holder name. | +| `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 | yes | The VPA to create — the full VPA `prefix@handle` (e.g. `919999999999-alice@setu`), max 255 characters, `^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$`. Its VPA prefix must start with the subscriber's mobile. | +| `accountNo` | string | yes | The wallet / pool account number. 6–18 characters, uppercase letters and digits — `^[A-Z0-9]{6,18}$`. | +| `accountName` | string | yes | The account-holder name. 1–99 characters; letters, digits, spaces and `. ' & -`. | | `otpRequired` | boolean | no | `true` (Android) — the VPA is created `pending-verification` and an [OTP](/payments/upi-issuance/onboarding/api-integration/user-otp) activates it. `false` (iOS, default) — the VPA is created `active`. | -| `ifsc` | string | no | The IFSC of the account. Optional when a TPAP default is configured. | -| `accountProviderId` | string | no | The account-provider id. Optional when a TPAP default is configured. | -| `programId` | string | no | ULID. Validated for existence when supplied. | +| `ifsc` | string | no | The IFSC of the account — `^[A-Z]{4}0[A-Z0-9]{6}$` (e.g. `SETU0000001`). Optional when a TPAP default is configured. | +| `accountProviderId` | string | no | The account-provider id — a 26-character ULID (`^[0-9A-HJKMNP-TV-Z]{26}$`). Optional when a TPAP default is configured. | +| `programId` | string | no | A 26-character ULID — `^[0-9A-HJKMNP-TV-Z]{26}$`. Validated for existence when supplied. | | `defaultDebit` | boolean | no | Make this the user's default debit account. | | `defaultCredit` | boolean | no | Make this the user's default credit account. | The account details are taken from the request. There is no bank round-trip at onboarding. +**Re-linking an existing VPA to a different account.** Re-sending `create-vpa` for a VPA you already hold is idempotent **only when the account details match**. If you re-create an existing active VPA with a **different account**, the call is rejected with `vpa-account-mismatch` (`409`) — deregister the VPA first, then create it against the new account. Setu never silently re-points an active VPA at a different account. This matters when a mobile number is reassigned to a new user: it is the app's / CBS's responsibility to clean up the prior user's VPAs, and this guard ensures an existing VPA cannot be quietly moved to a different account. + ##### Sample request @@ -146,8 +147,8 @@ Returns the created VPA; its `status` is `active` or `pending-verification`. | Status | Code | When | | :--- | :--- | :--- | -| 400 | `invalid-request` | `deviceId` or `mobile` is missing, or the request body is malformed. | -| 400 | `missing-parameter` | `vpa`, `accountNo`, or `accountName` is empty, or `ifsc` / `accountProviderId` is required (no TPAP default configured) and was not supplied. | +| 400 | `invalid-request` | `deviceId`, `mobile`, `vpa`, `accountNo`, or `accountName` is missing or fails format validation, or the request body is malformed. | +| 400 | `missing-parameter` | `ifsc` or `accountProviderId` is required (no TPAP default configured) and was not supplied. | | 400 | `invalid-program` | Unknown or inactive `programId`. | | 400 | `invalid-vpa` | The VPA prefix does not start with the subscriber's mobile number. | | 400 | `invalid-handle` | The VPA carries a handle that is not the one configured for this TPAP. | @@ -155,6 +156,7 @@ Returns the created VPA; its `status` is `active` or `pending-verification`. | 404 | `user-not-found` | No user for this mobile. | | 409 | `invalid-user-state` | The user is not `active`. | | 409 | `vpa-taken` | That VPA is already in use. | +| 409 | `vpa-account-mismatch` | This VPA is already active on a **different account**. Deregister it first to re-link it to another account. | | 500 | `internal-error` | Something went wrong on Setu's side. |
@@ -173,9 +175,9 @@ Returns the created VPA; its `status` is `active` or `pending-verification`. | 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 | yes | The VPA to fetch — the full VPA `prefix@handle` (e.g. `919999999999-alice@setu`). Its VPA prefix must start with the subscriber's mobile. | +| `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 | yes | The VPA to fetch — the full VPA `prefix@handle` (e.g. `919999999999-alice@setu`), max 255 characters, `^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$`. Its VPA prefix must start with the subscriber's mobile. | ##### Sample request @@ -218,7 +220,7 @@ Returns the VPA under `vpa`. | Status | Code | When | | :--- | :--- | :--- | -| 400 | `invalid-request` | `deviceId`, `mobile`, or `vpa` is missing, or the request body is malformed. | +| 400 | `invalid-request` | `deviceId`, `mobile`, or `vpa` is missing or fails format validation, or the request body is malformed. | | 400 | `invalid-vpa` | The VPA prefix does not start with the subscriber's mobile number. | | 400 | `invalid-handle` | The VPA carries a handle that is not the one configured for this TPAP. | | 401 | `device-not-linked` | This device is not bound for this user. | @@ -235,10 +237,10 @@ Returns the VPA under `vpa`. | 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`). | | `limit` | integer | no | Max entries this page. Omitted or non-positive defaults to `20`, clamped to `100`. | -| `cursor` | string | no | The `nextCursor` from the previous page. Omit for the first page. | +| `cursor` | string | no | The `nextCursor` from the previous page (opaque). Omit for the first page. | ##### Sample request @@ -283,7 +285,7 @@ Returns the VPA under `vpa`. | Status | Code | When | | :--- | :--- | :--- | -| 400 | `invalid-request` | `deviceId` or `mobile` is missing, the request body is malformed, or `cursor` is malformed. | +| 400 | `invalid-request` | `deviceId` or `mobile` is missing or fails format validation, the request body is malformed, or `cursor` is malformed. | | 401 | `device-not-linked` | This device is not bound for this user. | | 404 | `user-not-found` | No user for this mobile. | | 409 | `invalid-user-state` | The user is not `active`. | @@ -297,9 +299,9 @@ Returns the VPA under `vpa`. | 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 | yes | The VPA to deregister — the full VPA `prefix@handle` (e.g. `919999999999-alice@setu`). Its VPA prefix must start with the subscriber's mobile. | +| `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 | yes | The VPA to deregister — the full VPA `prefix@handle` (e.g. `919999999999-alice@setu`), max 255 characters, `^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$`. Its VPA prefix must start with the subscriber's mobile. | ##### Sample request @@ -342,8 +344,7 @@ Returns the deregistered VPA; its `status` is `deregistered`. | Status | Code | When | | :--- | :--- | :--- | -| 400 | `invalid-request` | `deviceId` or `mobile` is missing, or the request body is malformed. | -| 400 | `missing-parameter` | `vpa` is missing. | +| 400 | `invalid-request` | `deviceId`, `mobile`, or `vpa` is missing or fails format validation, or the request body is malformed. | | 400 | `invalid-vpa` | The VPA prefix does not start with the subscriber's mobile number. | | 400 | `invalid-handle` | The VPA carries a handle that is not the one configured for this TPAP. | | 401 | `device-not-linked` | This device is not bound for this user. | diff --git a/content/payments/upi-issuance/payee-blocklist.mdx b/content/payments/upi-issuance/payee-blocklist.mdx index 61ede0d9..6f5890ac 100644 --- a/content/payments/upi-issuance/payee-blocklist.mdx +++ b/content/payments/upi-issuance/payee-blocklist.mdx @@ -29,9 +29,9 @@ Let a user block payee VPAs they never want to transact with. All routes are env | 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`). | -| `payeeVpa` | string | yes | The external payee VPA to block (`prefix@handle`; any handle). | +| `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`). | +| `payeeVpa` | string | yes | The external payee VPA to block (`prefix@handle`; any handle). Max 255 characters — `^([A-Za-z0-9.-]+)@([A-Za-z0-9.-]+)$`. | ##### Sample request @@ -66,9 +66,7 @@ Let a user block payee VPAs they never want to transact with. All routes are env | Status | Code | When | | :--- | :--- | :--- | -| 400 | `invalid-request` | `deviceId` or `mobile` is missing, or the request body is malformed. | -| 400 | `missing-parameter` | `payeeVpa` is missing. | -| 400 | `invalid-vpa` | `payeeVpa` is not a valid VPA of the form `prefix@handle`. | +| 400 | `invalid-request` | `deviceId`, `mobile`, or `payeeVpa` is missing or fails format validation (`payeeVpa` must be `prefix@handle`, max 255 characters), 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. | | 409 | `invalid-user-state` | The user is not `active`. | @@ -114,9 +112,7 @@ Let a user block payee VPAs they never want to transact with. All routes are env | Status | Code | When | | :--- | :--- | :--- | -| 400 | `invalid-request` | `deviceId` or `mobile` is missing, or the request body is malformed. | -| 400 | `missing-parameter` | `payeeVpa` is missing. | -| 400 | `invalid-vpa` | `payeeVpa` is not a valid VPA of the form `prefix@handle`. | +| 400 | `invalid-request` | `deviceId`, `mobile`, or `payeeVpa` is missing or fails format validation (`payeeVpa` must be `prefix@handle`, max 255 characters), 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 | `payee-not-blocked` | That payee VPA is not on the user's blocklist. | @@ -131,10 +127,10 @@ Let a user block payee VPAs they never want to transact with. All routes are env | 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`). | | `limit` | integer | no | Max entries this page. Omitted or non-positive defaults to `20`, clamped to `100`. | -| `cursor` | string | no | The `nextCursor` from the previous page. Omit for the first page. | +| `cursor` | string | no | The `nextCursor` from the previous page (opaque). Omit for the first page. | ##### Sample request @@ -173,7 +169,7 @@ Let a user block payee VPAs they never want to transact with. All routes are env | Status | Code | When | | :--- | :--- | :--- | -| 400 | `invalid-request` | `deviceId` or `mobile` is missing, the request body is malformed, or `cursor` is malformed. | +| 400 | `invalid-request` | `deviceId` or `mobile` is missing or fails format validation, the request body is malformed, or `cursor` is malformed. | | 401 | `device-not-linked` | This device is not bound for this user. | | 404 | `user-not-found` | No user for this mobile. | | 409 | `invalid-user-state` | The user is not `active`. | From 84e5d83607cd30c261bd6691595063500049dc59 Mon Sep 17 00:00:00 2001 From: Anindya Pandey Date: Sun, 19 Jul 2026 23:44:03 +0530 Subject: [PATCH 3/3] docs(UPIIS-53): document the client-secret request signature The API envelope now carries clientId and sig on every request. Document the signing steps (sig = base64(HMAC-SHA256(clientSecret, plaintext body))), add the fields to the request format and the Python reference implementation, and update the quickstart credentials + authentication sections. A missing or invalid signature is rejected 401 invalid-signature. Closes UPIIS-53 --- .../payments/upi-issuance/api-envelope.mdx | 36 +++++++++++++++---- content/payments/upi-issuance/quickstart.mdx | 19 ++++++---- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/content/payments/upi-issuance/api-envelope.mdx b/content/payments/upi-issuance/api-envelope.mdx index cb8ecb6d..9f16d093 100644 --- a/content/payments/upi-issuance/api-envelope.mdx +++ b/content/payments/upi-issuance/api-envelope.mdx @@ -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).
### 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`: {`{ "ct": "", "sk": "", - "iv": "" + "iv": "", + "clientId": "", + "sig": "" }`} 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. +
+ +### 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`**. + + + 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. + + ### Response format The response is encrypted under the same session key and IV the TPAP / Issuing App generated: @@ -54,22 +72,26 @@ The TPAP / Issuing App decrypts `ct` with the session key and IV, then verifies ### Reference implementation (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"]) @@ -79,7 +101,7 @@ def decrypt_response(key, iv, enc: dict): return json.loads(plain)`} -`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 diff --git a/content/payments/upi-issuance/quickstart.mdx b/content/payments/upi-issuance/quickstart.mdx index a8f87d2b..32ec4945 100644 --- a/content/payments/upi-issuance/quickstart.mdx +++ b/content/payments/upi-issuance/quickstart.mdx @@ -17,15 +17,15 @@ This guide covers the one-time setup the TPAP / Issuing App needs before it can
-### The envelope public key +### Your keys and credentials -Every request and response body is encrypted with the **API envelope**. +Every request and response body is encrypted with the **API envelope**, and every request is signed with your client secret. -The TPAP / Issuing App can reach out to Setu to provide the **envelope public key**. With this key, the TPAP / Issuing App is supposed to encrypt each request and decrypt each response. +The TPAP / Issuing App reaches out to Setu, which provides three things: the **envelope public key** (to encrypt requests and decrypt responses), a **`clientId`**, and a **`clientSecret`** (to sign each request). -The [API envelope](/payments/upi-issuance/api-envelope) page has the full wire format and a copy-paste reference implementation. +The [API envelope](/payments/upi-issuance/api-envelope) page has the full wire format, the [signing steps](/payments/upi-issuance/api-envelope#signing-the-request), and a copy-paste reference implementation. -For example, a generate binding token request and its response look like this on the wire — the encrypted body (`ct`), the request's one-time key encrypted under Setu's public key (`sk`), and the IV (`iv`): +For example, a generate binding token request and its response look like this on the wire — the encrypted body (`ct`), the request's one-time key encrypted under Setu's public key (`sk`), the IV (`iv`), your `clientId`, and the request signature (`sig`): ##### Sample request @@ -33,7 +33,9 @@ For example, a generate binding token request and its response look like this on {`{ "ct": "HBAHaU47cY2pu9+9AkzlAYKr8s4CEDhz…Bn1uIfe9ufHgu1", "sk": "hLGbHiL+TbnjYJhCgWtZM8m/yoWb0bHO…DOENEuzVbdFrX4a70A==", - "iv": "OTLfPjWZQhMRCIoAsEBAtg==" + "iv": "OTLfPjWZQhMRCIoAsEBAtg==", + "clientId": "01KTVREBB5BM15AFA8SQBJGRS5", + "sig": "k7mS0mHiSx8s0cJ2c3Yb1xkZ0oJc9m8s5eJ8p8b2w4A=" }`}
@@ -52,7 +54,10 @@ The response comes back encrypted under the same key — the encrypted body (`ct ### Authentication -The TPAP / Issuing App can share its outbound IP address with Setu, and Setu would whitelist that IP. This is how the TPAP / Issuing App is authenticated, so requests must be server-to-server. +Requests are authenticated two ways, together: + +1. **IP whitelisting.** The TPAP / Issuing App shares its outbound IP address with Setu, and Setu whitelists it. Requests must be server-to-server. +2. **Client-secret signature.** Each request carries your `clientId` and a `sig` — an HMAC of the request payload keyed by your `clientSecret` — so Setu can verify the call is genuinely from you. See [Signing the request](/payments/upi-issuance/api-envelope#signing-the-request). Keep the `clientSecret` on your backend only.