diff --git a/URMIOTP_API.md b/URMIOTP_API.md new file mode 100644 index 0000000..e4ffd78 --- /dev/null +++ b/URMIOTP_API.md @@ -0,0 +1,310 @@ +# UrmiOTP — developer API + +Unified OTP API for Urmi developers. Use this document to integrate OTP delivery into your application. + +**Related docs** + +| Document | Purpose | +|----------|---------| +| [OTP_SMS_IMPLEMENTATION.md](OTP_SMS_IMPLEMENTATION.md) | Direct Ultronsms gateway integration (standalone, not used by UrmiOTP API today) | +| [GENERIC_TEMPLATE_SMS.md](GENERIC_TEMPLATE_SMS.md) | Generic DLT template SMS via Ultronsms | +| `/api/v1/messages/docs/send` | Per-account DLT SMS API (actually delivers SMS today) | + +--- + +## How it is implemented + +UrmiOTP is a **project-gated, template-based OTP layer** on top of the Urmi control-plane API. It is **not** a standalone SMS gateway integration. + +### Architecture + +``` +┌──────────────────┐ POST /api/v1/otp/generate ┌─────────────────┐ +│ Developer app │ ────────────────────────────► │ routes_otp.py │ +│ (API key auth) │ └────────┬────────┘ +└──────────────────┘ │ + ▼ +┌──────────────────┐ POST /api/v1/urmiotp/generate ┌─────────────────┐ +│ Urmi console │ ───────────────────────────────►│ routes_urmiotp │ +│ (session auth) │ └────────┬────────┘ +└──────────────────┘ │ + ▼ + ┌─────────────────┐ + │ otp_service.py │ + │ generate_otp_ │ + │ for_user() │ + └────────┬────────┘ + │ + ┌────────────────────────────────────────┼────────────────────────┐ + ▼ ▼ ▼ ▼ + urmiotp_catalog.py UrmiOtpProject normalize_recipients() record_otp_log() + (7 hardcoded (approved project (phone formatting) → otp_message_logs + templates) lookup) +``` + +Both entry points call the same core function: `generate_otp_for_user()` in `backend/app/otp_service.py`. + +### Onboarding flow + +1. **Developer** creates an UrmiOTP project in the console (`POST /api/v1/urmiotp/projects`). + - Provides `project_name`, `message_label` (product name shown in SMS), `app_type`, `app_url`, and `purpose`. + - Project starts with `status: pending`. +2. **Admin** reviews and approves the project (`PATCH /api/v1/admin/compliance/urmiotp-projects/{id}`). + - Only `approved` projects can call the generate API. +3. **Developer** creates an API key (`urmi_live_...`) under **Develop → API Keys**. +4. **Developer** calls `POST /api/v1/otp/generate` with the project name, template type, recipient, and OTP variables. + +Per-project documentation (templates with your `message_label` filled in) is available at: + +`GET /api/v1/urmiotp/projects/{project_id}/documentation` + +(Requires console session auth; available only after approval.) + +### Message composition + +Templates are defined in `backend/app/urmiotp_catalog.py` — seven fixed DLT-style message patterns. Each template has: + +- `{message_label}` — replaced with the project's registered label (e.g. `Acme App`) +- `{#var#}` × 2 — replaced in order with `variables.otp` and `variables.validity_minutes` + +Example for `login_authentication` with label `Acme`: + +``` +Your URMI login verification code for Acme is 583921. It is valid for 10 minutes. - URMI +``` + +**Urmi does not generate the OTP code.** Your backend must create the numeric code, pass it in `variables.otp`, store it server-side with a TTL, and verify it when the user submits it. + +### What happens on generate + +When `POST /api/v1/otp/generate` is called, `otp_service.py`: + +1. Validates `template_type` against the catalog. +2. Loads the caller's **approved** `UrmiOtpProject` by `project_name`. +3. Normalizes the recipient number (10-digit Indian numbers get `91` prefix). +4. Builds the final SMS text via `_build_otp_message()`. +5. Writes a row to `otp_message_logs` with `status: accepted`. +6. Returns `message_preview` and `log_id`. + +### SMS delivery + +`generate_otp_for_user()` sends SMS via `UltronsmsClient.send_template_sms()` using the DLT-approved template from [OTP_SMS_IMPLEMENTATION.md](OTP_SMS_IMPLEMENTATION.md): + +``` +Your URMI app verification code is {otp} for {message_label}. It is valid for {validity_minutes} minutes. Do not share it to anyone - URMI AI +``` + +| Setting | Value | +|---------|-------| +| Sender ID | `URMIAI` | +| PE ID | `1701178780498045011` | +| DLT template ID | `1777178797989033323` | + +`template_type` (e.g. `login_authentication`) is used for logging and analytics only. All types deliver the same DLT-registered SMS body. + +Credentials come from `ULTRONSMS_USER` and `ULTRONSMS_PASSWORD` in the server environment. + +### Observability + +OTP requests are stored in `otp_message_logs` and surfaced in: + +- **Develop → Logs** (`GET /api/v1/logs/otp`) +- **Develop → Analytics** (grouped by project and template type) + +--- + +## API reference + +### Generate OTP + +| | | +|---|---| +| **Method** | `POST` | +| **Path** | `/api/v1/otp/generate` | +| **Auth** | API key (`Authorization: Bearer urmi_live_...` or `X-Api-Key: urmi_live_...`) | +| **Content-Type** | `application/json` | + +#### Request body + +```json +{ + "project_name": "acme-login", + "template_type": "login_authentication", + "to": "919999999999", + "variables": { + "otp": "583921", + "validity_minutes": "10" + } +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `project_name` | string | yes | Your approved UrmiOTP project name (3–30 chars) | +| `template_type` | string | yes | One of the template types below | +| `to` | string | yes | Recipient mobile with country code (10–15 digits) | +| `variables.otp` | string | yes | OTP value to embed in the message | +| `variables.validity_minutes` | string | yes | Validity duration in minutes | + +#### Template types + +| `template_type` | Use case | +|-----------------|----------| +| `general_verification` | Signup, KYC, general verification | +| `login_authentication` | Login, MFA, 2FA | +| `account_recovery` | Forgot password, account recovery | +| `account_change` | Change phone, email, or password | +| `action_authorization` | Sensitive actions, API key changes | +| `transaction_verification` | Payments, transfers | +| `device_verification` | Device pairing, IoT activation | + +#### Success response (`200`) + +```json +{ + "success": true, + "log_id": "550e8400-e29b-41d4-a716-446655440000", + "project_name": "acme-login", + "template_type": "login_authentication", + "to": "919999999999", + "message_preview": "Your URMI app verification code is 583921. It is valid for 10 minutes. Do not share it to anyone - URMI AI", + "status": "sent", + "job_id": "215488915", + "message_id": "5h6krUyQsU64iM0otKX7kA" +} +``` + +#### Error responses + +| HTTP | Cause | +|------|-------| +| `401` | Missing or invalid API key | +| `403` | Account inactive | +| `404` | UrmiOTP project not found for this user | +| `422` | Project not approved, unknown `template_type`, missing `variables`, or invalid phone number | + +--- + +## Authentication + +Create an API key in the Urmi console under **Develop → API Keys**. + +``` +Authorization: Bearer urmi_live_ +``` + +Alternate header: + +``` +X-Api-Key: urmi_live_ +``` + +API keys use the `urmi_live_` prefix. Implementation: `backend/app/api_key_auth.py`. + +--- + +## Examples + +### cURL + +```bash +curl -X POST "https://urmiai.com/api/v1/otp/generate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer urmi_live_YOUR_KEY_HERE" \ + -d '{ + "project_name": "acme-login", + "template_type": "login_authentication", + "to": "919999999999", + "variables": { + "otp": "583921", + "validity_minutes": "10" + } + }' +``` + +Local development base URL: `http://localhost:8000` + +### Python + +```python +import httpx + +API_BASE = "https://urmiai.com" +API_KEY = "urmi_live_YOUR_KEY_HERE" + +response = httpx.post( + f"{API_BASE}/api/v1/otp/generate", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={ + "project_name": "acme-login", + "template_type": "login_authentication", + "to": "919999999999", + "variables": {"otp": "583921", "validity_minutes": "10"}, + }, + timeout=30, +) +response.raise_for_status() +print(response.json()) +``` + +### Node.js + +```javascript +const response = await fetch("https://urmiai.com/api/v1/otp/generate", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer urmi_live_YOUR_KEY_HERE", + }, + body: JSON.stringify({ + project_name: "acme-login", + template_type: "login_authentication", + to: "919999999999", + variables: { otp: "583921", validity_minutes: "10" }, + }), +}); +const data = await response.json(); +``` + +--- + +## Console testing (no API key) + +For testing inside the Urmi console or **Develop → Testing Center**, use the session-authenticated variant: + +| | | +|---|---| +| **Method** | `POST` | +| **Path** | `/api/v1/urmiotp/generate` | +| **Auth** | Console session cookie | + +Same request body as `/api/v1/otp/generate`. + +--- + +## Integration checklist + +- [ ] Create and get an UrmiOTP project approved +- [ ] Create an API key (`urmi_live_...`) +- [ ] Generate OTP codes on **your** backend (Urmi does not generate them) +- [ ] Store OTP server-side with TTL matching `validity_minutes` +- [ ] Verify user-submitted OTP on your backend before marking session verified +- [ ] Rate-limit OTP requests per phone number and IP +- [ ] Do not return the OTP in your own API responses to the client +- [ ] Do not log full OTP values in application logs +- [ ] Handle non-`200` responses with a generic user message ("Unable to send OTP, try again") + +--- + +## Source files + +| File | Role | +|------|------| +| `backend/app/routes_otp.py` | External API route (`/api/v1/otp/generate`) | +| `backend/app/routes_urmiotp.py` | Console routes (projects, docs, session generate) | +| `backend/app/otp_service.py` | Core generate logic | +| `backend/app/urmiotp_catalog.py` | Template catalog and API metadata | +| `backend/app/log_service.py` | `record_otp_log()` → `otp_message_logs` | +| `backend/app/models.py` | `UrmiOtpProject`, `OtpMessageLog` models | +| `backend/app/api_key_auth.py` | API key authentication | +| `frontend/src/features/agent/UrmiOtpDocsPage.tsx` | In-console API documentation UI | diff --git a/backend/app/otp_service.py b/backend/app/otp_service.py index ebbd32f..2ab0859 100644 --- a/backend/app/otp_service.py +++ b/backend/app/otp_service.py @@ -6,7 +6,8 @@ from sqlalchemy.orm import Session from .log_service import record_otp_log from .message_send_service import SendMessageError, normalize_recipients from .models import UrmiOtpProject, UserAccount -from .urmiotp_catalog import OTP_TEMPLATES +from .ultronsms_client import UltronsmsClient +from .urmiotp_catalog import OTP_TEMPLATES, URMI_OTP_SMS_CONFIG, build_dlt_otp_message TEMPLATE_TYPES = {template["type"] for template in OTP_TEMPLATES} @@ -18,11 +19,7 @@ class GenerateOtpPayload(BaseModel): variables: dict[str, str] = Field(default_factory=dict) -def _build_otp_message(template_type: str, message_label: str, variables: dict[str, str]) -> str: - template = next((item for item in OTP_TEMPLATES if item["type"] == template_type), None) - if not template: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unknown template type") - +def _require_otp_variables(variables: dict[str, str]) -> None: otp_value = variables.get("otp", "").strip() validity = variables.get("validity_minutes", "").strip() if not otp_value or not validity: @@ -31,11 +28,6 @@ def _build_otp_message(template_type: str, message_label: str, variables: dict[s detail="variables.otp and variables.validity_minutes are required", ) - content = template["content"].replace("{message_label}", message_label) - for value in (otp_value, validity): - content = content.replace("{#var#}", value, 1) - return content - def generate_otp_for_user( db: Session, @@ -68,7 +60,52 @@ def generate_otp_for_user( raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc recipient = recipients[0] - message = _build_otp_message(payload.template_type, project.message_label, payload.variables) + _require_otp_variables(payload.variables) + try: + message = build_dlt_otp_message(payload.variables, project.message_label) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + client = UltronsmsClient() + try: + send_result = client.send_template_sms( + mobile=recipient, + message=message, + sender_id=URMI_OTP_SMS_CONFIG["sender_id"], + pe_id=URMI_OTP_SMS_CONFIG["pe_id"], + dlt_template_id=URMI_OTP_SMS_CONFIG["dlt_template_id"], + sender_type="transactional", + ) + except RuntimeError as exc: + entry = record_otp_log( + db, + user, + project_name=project.project_name, + template_type=payload.template_type, + to_number=recipient, + status="failed", + error_message=str(exc), + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + + if not send_result.success: + error_message = send_result.raw.get("ErrorMessage") or "SMS gateway rejected the request" + entry = record_otp_log( + db, + user, + project_name=project.project_name, + template_type=payload.template_type, + to_number=recipient, + status="failed", + error_message=error_message, + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=error_message, + ) entry = record_otp_log( db, @@ -76,7 +113,9 @@ def generate_otp_for_user( project_name=project.project_name, template_type=payload.template_type, to_number=recipient, - status="accepted", + status="sent", + job_id=send_result.job_id, + message_id=send_result.message_id, ) return { @@ -84,7 +123,9 @@ def generate_otp_for_user( "log_id": str(entry.id), "project_name": project.project_name, "template_type": payload.template_type, - "to": recipient, + "to": send_result.number or recipient, "message_preview": message, "status": entry.status, + "job_id": send_result.job_id, + "message_id": send_result.message_id, } diff --git a/backend/app/urmiotp_catalog.py b/backend/app/urmiotp_catalog.py index 0377b86..ba9c2bb 100644 --- a/backend/app/urmiotp_catalog.py +++ b/backend/app/urmiotp_catalog.py @@ -74,6 +74,35 @@ def render_templates_for_project(message_label: str) -> list[dict]: return rendered +# DLT-approved SMS body for Ultronsms (template ID 1777178797989033323). +# All UrmiOTP template_type values are delivered using this registered text. +URMI_OTP_SMS_CONFIG = { + "sender_id": "URMIAI", + "pe_id": "1701178780498045011", + "dlt_template_id": "1777178797989033323", + "message_template": ( + "Your URMI app verification code is {otp} for {message_label}. " + "It is valid for {validity_minutes} minutes. " + "Do not share it to anyone - URMI AI" + ), +} + + +def build_dlt_otp_message(variables: dict[str, str], message_label: str) -> str: + otp_value = variables.get("otp", "").strip() + validity = variables.get("validity_minutes", "").strip() + label = message_label.strip() + if not otp_value or not validity: + raise ValueError("variables.otp and variables.validity_minutes are required") + if not label: + raise ValueError("message_label is required") + return URMI_OTP_SMS_CONFIG["message_template"].format( + otp=otp_value, + message_label=label, + validity_minutes=validity, + ) + + OTP_API_DOCUMENTATION = { "method": "POST", "path": "/api/v1/otp/generate", diff --git a/frontend/src/features/agent/UrmiOtpDocsPage.tsx b/frontend/src/features/agent/UrmiOtpDocsPage.tsx index 1870103..5cfe598 100644 --- a/frontend/src/features/agent/UrmiOtpDocsPage.tsx +++ b/frontend/src/features/agent/UrmiOtpDocsPage.tsx @@ -52,7 +52,10 @@ export function UrmiOtpDocsPage() {
Endpoint{docs.api.path}
- This is documentation only. The UrmiOTP API is not enabled yet. + + SMS delivery uses the DLT-approved URMI OTP template. template_type is for + categorization; the sent text matches message_preview in the API response. +
{Object.entries(docs.api.parameters).map(([name, description]) => (