from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload from .database import get_db from .endpoint_security import endpoint_signing_secret, validate_endpoint_url from .models import ( PhoneNumber, SmsDeliveryWebhook, UserAccount, VoiceSocketEndpoint, ) from .sms_webhooks import deliver_test_report from .voice_auth import current_voice_user router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"]) class EndpointCreate(BaseModel): name: str = Field(min_length=2, max_length=120) phone_number_id: UUID url: str = Field(min_length=8, max_length=500) description: str | None = Field(default=None, max_length=500) enabled: bool = True class EndpointUpdate(BaseModel): name: str | None = Field(default=None, min_length=2, max_length=120) phone_number_id: UUID | None = None url: str | None = Field(default=None, min_length=8, max_length=500) description: str | None = Field(default=None, max_length=500) enabled: bool | None = None def _owned_number(db: Session, user: UserAccount, number_id: UUID) -> PhoneNumber: number = db.scalar( select(PhoneNumber).where( PhoneNumber.id == number_id, PhoneNumber.user_id == user.id, PhoneNumber.status == "assigned", ) ) if not number: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found") return number def _clean_name(value: str) -> str: name = " ".join(value.split()) if len(name) < 2: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name must contain at least 2 characters", ) return name def _commit(db: Session, duplicate_detail: str) -> None: try: db.commit() except IntegrityError as exc: db.rollback() raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=duplicate_detail) from exc def _serialize_socket(endpoint: VoiceSocketEndpoint, *, include_secret: bool = False) -> dict: payload = { "id": str(endpoint.id), "name": endpoint.name, "phone_number_id": str(endpoint.phone_number_id), "phone_number": endpoint.phone_number.phone_number, "url": endpoint.url, "enabled": endpoint.enabled, "created_at": endpoint.created_at, "updated_at": endpoint.updated_at, } if include_secret: payload["signing_secret"] = endpoint_signing_secret("socket", endpoint.id) return payload def _serialize_webhook(endpoint: SmsDeliveryWebhook, *, include_secret: bool = False) -> dict: payload = { "id": str(endpoint.id), "name": endpoint.name, "phone_number_id": str(endpoint.phone_number_id), "phone_number": endpoint.phone_number.phone_number, "url": endpoint.url, "description": endpoint.description, "event": "message.delivery.updated", "enabled": endpoint.enabled, "created_at": endpoint.created_at, "updated_at": endpoint.updated_at, } if include_secret: payload["signing_secret"] = endpoint_signing_secret("webhook", endpoint.id) return payload @router.get("/docs") def integration_docs() -> dict: return { "title": "Webhooks & Sockets", "summary": "Connect each Urmi number to your application.", "relay": { "principle": "Urmi forwards raw WebSocket and HTTP payloads without decoding or modifying them. We authenticate requests and route traffic — all processing happens on your infrastructure.", "latency": "Voice media frames are relayed as they arrive to keep latency as low as possible.", }, "socket": { "title": "Voice sockets", "description": "Link a secure WebSocket endpoint to a number. When a call connects, Urmi opens your endpoint and relays frames in both directions with no audio processing on our side.", "events": ["connected", "start", "media", "end"], "authentication": { "headers": ["X-Urmi-Endpoint-Id", "X-Urmi-Timestamp", "X-Urmi-Signature"], "signature": "HMAC-SHA256 of '.' using the signing secret.", "replay_protection": "Reject timestamps older than five minutes.", }, }, "webhook": { "title": "SMS delivery webhooks", "description": "Link a named HTTPS endpoint to a number to receive SMS delivery status updates.", "event": "message.delivery.updated", "statuses": ["queued", "sent", "delivered", "undelivered", "failed"], "authentication": { "headers": ["X-Urmi-Event", "X-Urmi-Endpoint-Id", "X-Urmi-Signature"], "signature": "HMAC-SHA256 of the raw request body using the signing secret.", }, "example": { "id": "evt_01J8EXAMPLE", "event": "message.delivery.updated", "created_at": "2026-09-04T09:30:00Z", "data": { "message_id": "msg_01J8EXAMPLE", "to": "+919999999999", "from": "+917946350706", "status": "delivered", "updated_at": "2026-09-04T09:29:58Z", }, }, }, "api_authentication": { "header": "Authorization", "format": "Bearer urmi_live_", "note": "Use API keys only from your server. Never expose them in browser code.", }, "endpoints": [ {"method": "GET", "path": "/api/v1/integrations/sockets", "description": "List voice sockets"}, {"method": "POST", "path": "/api/v1/integrations/sockets", "description": "Create and link a voice socket"}, {"method": "PATCH", "path": "/api/v1/integrations/sockets/{id}", "description": "Update or pause a voice socket"}, {"method": "DELETE", "path": "/api/v1/integrations/sockets/{id}", "description": "Delete a voice socket"}, {"method": "GET", "path": "/api/v1/integrations/webhooks", "description": "List SMS delivery webhooks"}, {"method": "POST", "path": "/api/v1/integrations/webhooks", "description": "Create and link a delivery webhook"}, {"method": "PATCH", "path": "/api/v1/integrations/webhooks/{id}", "description": "Update or pause a delivery webhook"}, {"method": "DELETE", "path": "/api/v1/integrations/webhooks/{id}", "description": "Delete a delivery webhook"}, ], } @router.get("/sockets") def list_sockets( db: Session = Depends(get_db), user: UserAccount = Depends(current_voice_user), ) -> list[dict]: endpoints = db.scalars( select(VoiceSocketEndpoint) .options(joinedload(VoiceSocketEndpoint.phone_number)) .where(VoiceSocketEndpoint.user_id == user.id) .order_by(VoiceSocketEndpoint.created_at.desc()) ).all() return [_serialize_socket(endpoint) for endpoint in endpoints] @router.post("/sockets", status_code=status.HTTP_201_CREATED) def create_socket( payload: EndpointCreate, db: Session = Depends(get_db), user: UserAccount = Depends(current_voice_user), ) -> dict: number = _owned_number(db, user, payload.phone_number_id) try: url = validate_endpoint_url(payload.url, websocket=True) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc endpoint = VoiceSocketEndpoint( user_id=user.id, phone_number_id=number.id, name=_clean_name(payload.name), url=url, enabled=payload.enabled, ) db.add(endpoint) _commit(db, "This name or phone number already has a voice socket") db.refresh(endpoint) endpoint.phone_number = number return _serialize_socket(endpoint, include_secret=True) @router.patch("/sockets/{endpoint_id}") def update_socket( endpoint_id: UUID, payload: EndpointUpdate, db: Session = Depends(get_db), user: UserAccount = Depends(current_voice_user), ) -> dict: endpoint = db.scalar( select(VoiceSocketEndpoint) .options(joinedload(VoiceSocketEndpoint.phone_number)) .where(VoiceSocketEndpoint.id == endpoint_id, VoiceSocketEndpoint.user_id == user.id) ) if not endpoint: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice socket not found") if payload.name is not None: endpoint.name = _clean_name(payload.name) if payload.phone_number_id is not None: endpoint.phone_number = _owned_number(db, user, payload.phone_number_id) endpoint.phone_number_id = payload.phone_number_id if payload.url is not None: try: endpoint.url = validate_endpoint_url(payload.url, websocket=True) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc if payload.enabled is not None: endpoint.enabled = payload.enabled _commit(db, "This name or phone number already has a voice socket") db.refresh(endpoint) return _serialize_socket(endpoint) @router.delete("/sockets/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_socket( endpoint_id: UUID, db: Session = Depends(get_db), user: UserAccount = Depends(current_voice_user), ) -> None: endpoint = db.scalar( select(VoiceSocketEndpoint).where( VoiceSocketEndpoint.id == endpoint_id, VoiceSocketEndpoint.user_id == user.id, ) ) if not endpoint: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice socket not found") db.delete(endpoint) db.commit() @router.get("/webhooks") def list_webhooks( db: Session = Depends(get_db), user: UserAccount = Depends(current_voice_user), ) -> list[dict]: endpoints = db.scalars( select(SmsDeliveryWebhook) .options(joinedload(SmsDeliveryWebhook.phone_number)) .where(SmsDeliveryWebhook.user_id == user.id) .order_by(SmsDeliveryWebhook.created_at.desc()) ).all() return [_serialize_webhook(endpoint) for endpoint in endpoints] @router.post("/webhooks", status_code=status.HTTP_201_CREATED) def create_webhook( payload: EndpointCreate, db: Session = Depends(get_db), user: UserAccount = Depends(current_voice_user), ) -> dict: number = _owned_number(db, user, payload.phone_number_id) try: url = validate_endpoint_url(payload.url, websocket=False) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc endpoint = SmsDeliveryWebhook( user_id=user.id, phone_number_id=number.id, name=_clean_name(payload.name), url=url, description=payload.description.strip() if payload.description else None, enabled=payload.enabled, ) db.add(endpoint) _commit(db, "A webhook with this name already exists") db.refresh(endpoint) endpoint.phone_number = number return _serialize_webhook(endpoint, include_secret=True) @router.patch("/webhooks/{endpoint_id}") def update_webhook( endpoint_id: UUID, payload: EndpointUpdate, db: Session = Depends(get_db), user: UserAccount = Depends(current_voice_user), ) -> dict: endpoint = db.scalar( select(SmsDeliveryWebhook) .options(joinedload(SmsDeliveryWebhook.phone_number)) .where(SmsDeliveryWebhook.id == endpoint_id, SmsDeliveryWebhook.user_id == user.id) ) if not endpoint: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found") if payload.name is not None: endpoint.name = _clean_name(payload.name) if payload.phone_number_id is not None: endpoint.phone_number = _owned_number(db, user, payload.phone_number_id) endpoint.phone_number_id = payload.phone_number_id if payload.url is not None: try: endpoint.url = validate_endpoint_url(payload.url, websocket=False) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc if "description" in payload.model_fields_set: endpoint.description = payload.description.strip() if payload.description else None if payload.enabled is not None: endpoint.enabled = payload.enabled _commit(db, "A webhook with this name already exists") db.refresh(endpoint) return _serialize_webhook(endpoint) @router.post("/webhooks/{endpoint_id}/test") async def test_webhook( endpoint_id: UUID, db: Session = Depends(get_db), user: UserAccount = Depends(current_voice_user), ) -> dict: endpoint = db.scalar( select(SmsDeliveryWebhook).where( SmsDeliveryWebhook.id == endpoint_id, SmsDeliveryWebhook.user_id == user.id, ) ) if not endpoint: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found") try: response_code = await deliver_test_report(endpoint) except RuntimeError as exc: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc), ) from exc return {"delivered": True, "response_code": response_code} @router.delete("/webhooks/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_webhook( endpoint_id: UUID, db: Session = Depends(get_db), user: UserAccount = Depends(current_voice_user), ) -> None: endpoint = db.scalar( select(SmsDeliveryWebhook).where( SmsDeliveryWebhook.id == endpoint_id, SmsDeliveryWebhook.user_id == user.id, ) ) if not endpoint: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found") db.delete(endpoint) db.commit()