Response:
Here is the production-ready implementation for the webhook testing endpoint, following Sunaiva's architecture standards.

## Project Structure

```
sunaiva-backend/
├── app/
│   ├── api/
│   │   └── v1/
│   │       └── webhooks.py          # Route handlers
│   ├── core/
│   │   ├── config.py                # Settings
│   │   └── exceptions.py            # Custom exceptions
│   ├── dependencies/
│   │   ├── auth.py                  # User extraction
│   │   └── database.py              # Redis/DB clients
│   ├── models/
│   │   └── webhook.py               # Pydantic schemas
│   ├── services/
│   │   └── webhook_test.py          # Business logic
│   └── main.py                      # FastAPI app
└── tests/
    └── test_webhook_endpoints.py    # Unit tests
```

## Implementation

### 1. Core Configuration & Exceptions

```python
# app/core/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache

class Settings(BaseSettings):
    """Application configuration with environment variable support."""

    # Redis Configuration
    redis_url: str = "redis://localhost:6379/0"
    redis_pool_size: int = 20

    # Webhook Test Settings
    webhook_test_max_items: int = 10
    webhook_test_ttl_seconds: int = 86400  # 24 hours cleanup

    # Security
    api_key_header: str = "X-API-Key"

    class Config:
        env_file = ".env"
        case_sensitive = False

@lru_cache()
def get_settings() -> Settings:
    """Cached settings instance for dependency injection."""
    return Settings()
```

```python
# app/core/exceptions.py
from fastapi import HTTPException, status

class WebhookTestError(HTTPException):
    """Base exception for webhook testing operations."""

    def __init__(self, detail: str, status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR):
        super().__init__(status_code=status_code, detail=detail)

class WebhookStorageError(WebhookTestError):
    """Raised when Redis storage operations fail."""

    def __init__(self, detail: str = "Failed to store webhook data"):
        super().__init__(detail=detail, status_code=status.HTTP_503_SERVICE_UNAVAILABLE)

class WebhookNotFoundError(WebhookTestError):
    """Raised when requested webhooks are not found."""

    def __init__(self, detail: str = "No webhooks found"):
        super().__init__(detail=detail, status_code=status.HTTP_404_NOT_FOUND)
```

### 2. Pydantic Models

```python
# app/models/webhook.py
from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID, uuid4

from pydantic import BaseModel, ConfigDict, Field, field_serializer

class WebhookHeaders(BaseModel):
    """Container for HTTP headers with flexible typing."""

    model_config = ConfigDict(extra="allow")

    content_type: Optional[str] = Field(None, alias="content-type")
    user_agent: Optional[str] = Field(None, alias="user-agent")
    x_forwarded_for: Optional[str] = Field(None, alias="x-forwarded-for")

class WebhookPayload(BaseModel):
    """Incoming webhook payload with raw body preservation."""

    model_config = ConfigDict(extra="allow")

    raw_body: Optional[str] = Field(None, description="Raw request body as string")
    json_body: Optional[Dict[str, Any]] = Field(None, description="Parsed JSON body")
    content_type: str = Field(default="application/json")

class StoredWebhook(BaseModel):
    """Complete webhook record stored in Redis."""

    model_config = ConfigDict(json_schema_serialize_as_any=True)

    id: UUID = Field(default_factory=uuid4)
    user_id: str = Field(..., description="Owner of this webhook record")
    received_at: datetime = Field(default_factory=datetime.utcnow)
    method: str = Field(default="POST", description="HTTP method used")
    headers: Dict[str, str] = Field(default_factory=dict, description="HTTP headers")
    query_params: Dict[str, str] = Field(default_factory=dict, description="URL query parameters")
    payload: WebhookPayload = Field(..., description="Request body content")
    source_ip: Optional[str] = Field(None, description="Client IP address")

    @field_serializer("received_at")
    def serialize_datetime(self, value: datetime) -> str:
        """ISO format with timezone for consistent serialization."""
        return value.isoformat() + "Z"

    @field_serializer("id")
    def serialize_uuid(self, value: UUID) -> str:
        return str(value)

class WebhookListResponse(BaseModel):
    """Response wrapper for listing webhooks."""

    webhooks: List[StoredWebhook]
    total_count: int = Field(..., description="Total items in the list (max 10)")
    user_id: str

    model_config = ConfigDict(json_schema_serialize_as_any=True)
```

### 3. Dependencies

```python
# app/dependencies/auth.py
from typing import Annotated

from fastapi import Depends, Header, HTTPException, status

async def get_current_user_id(
    x_api_key: Annotated[str | None, Header()] = None,
    authorization: Annotated[str | None, Header()] = None,
) -> str:
    """
    Extract user ID from API key or JWT token.

    In production, this validates against the database or auth service.
    For testing, we extract a user identifier from headers.
    """
    if x_api_key:
        # In production: validate against DB, hash comparison, etc.
        # For now, we use the key as the user identifier (simplified)
        return f"user_{x_api_key[:8]}"

    if authorization and authorization.startswith("Bearer "):
        # JWT validation would go here
        token = authorization.replace("Bearer ", "")
        return f"user_{token[:8]}"

    # For development/testing only - remove in production
    raise HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Valid API key or authorization header required",
        headers={"WWW-Authenticate": "Bearer"},
    )
```

```python
# app/dependencies/database.py
import json
from typing import AsyncGenerator

import redis.asyncio as redis
from fastapi import Depends

from app.core.config import get_settings, Settings

async def get_redis_client(
    settings: Settings = Depends(get_settings)
) -> AsyncGenerator[redis.Redis, None]:
    """
    Yield Redis client with connection pooling.

    Uses async Redis client for non-blocking operations.
    """
    client = redis.Redis.from_url(
        settings.redis_url,
        encoding="utf-8",
        decode_responses=True,
        max_connections=settings.redis_pool_size,
    )

    try:
        yield client
    finally:
        await client.close()

class WebhookTestRepository:
    """Repository pattern for webhook test data in Redis."""

    def __init__(self, redis_client: redis.Redis, max_items: int = 10):
        self.redis = redis_client
        self.max_items = max_items
        self.key_prefix = "webhook:test"

    def _get_key(self, user_id: str) -> str:
        """Generate Redis key for user-specific webhook list."""
        return f"{self.key_prefix}:{user_id}"

    async def add_webhook(self, user_id: str, webhook_data: dict) -> None:
        """
        Store webhook with FIFO eviction (keep last N items).

        Uses Redis LPUSH + LTRIM for atomic FIFO operations.
        """
        key = self._get_key(user_id)
        serialized = json.dumps(webhook_data)

        # Use pipeline for atomic operations
        pipe = self.redis.pipeline()
        pipe.lpush(key, serialized)
        pipe.ltrim(key, 0, self.max_items - 1)  # Keep 0-9 (10 items)
        pipe.expire(key, 86400)  # 24h TTL for cleanup
        await pipe.execute()

    async def get_webhooks(self, user_id: str) -> list[dict]:
        """Retrieve all stored webhooks for user (newest first)."""
        key = self._get_key(user_id)
        data = await self.redis.lrange(key, 0, -1)
        return [json.loads(item) for item in data] if data else []

    async def clear_webhooks(self, user_id: str) -> int:
        """Delete all test webhooks for user. Returns number deleted."""
        key = self._get_key(user_id)
        # Get count before deletion for reporting
        count = await self.redis.llen(key)
        if count > 0:
            await self.redis.delete(key)
        return count

async def get_webhook_repository(
    redis: redis.Redis = Depends(get_redis_client),
) -> WebhookTestRepository:
    """Factory for webhook repository with configured limits."""
    settings = get_settings()
    return WebhookTestRepository(redis, max_items=settings.webhook_test_max_items)
```

### 4. Service Layer

```python
# app/services/webhook_test.py
import logging
from typing import List
from uuid import UUID

from fastapi import Request

from app.core.exceptions import WebhookStorageError
from app.dependencies.database import WebhookTestRepository
from app.models.webhook import StoredWebhook, WebhookListResponse, WebhookPayload

logger = logging.getLogger(__name__)

class WebhookTestService:
    """Business logic for webhook testing functionality."""

    def __init__(self, repository: WebhookTestRepository):
        self.repo = repository

    async def store_incoming_webhook(
        self,
        user_id: str,
        request: Request,
        raw_body: bytes,
    ) -> StoredWebhook:
        """
        Process and store incoming webhook request.

        Extracts metadata, parses payload, and persists to Redis.
        """
        try:
            # Extract headers (filter out sensitive ones)
            headers = dict(request.headers)
            safe_headers = {
                k: v for k, v in headers.items()
                if k.lower() not in {'authorization', 'cookie', 'x-api-key'}
            }

            # Parse body based on content type
            content_type = headers.get('content-type', 'application/json')
            payload = WebhookPayload(content_type=content_type)

            if raw_body:
                try:
                    # Try JSON first
                    import json
                    payload.json_body = json.loads(raw_body)
                except json.JSONDecodeError:
                    # Fall back to raw string
                    payload.raw_body = raw_body.decode('utf-8', errors='replace')

            # Build stored record
            webhook = StoredWebhook(
                user_id=user_id,
                method=request.method,
                headers=safe_headers,
                query_params=dict(request.query_params),
                payload=payload,
                source_ip=request.client.host if request.client else None,
            )

            # Persist to Redis (as dict for JSON serialization)
            await self.repo.add_webhook(user_id, webhook.model_dump(mode='json'))

            logger.info(
                f"Stored test webhook {webhook.id} for user {user_id[:8]}...",
                extra={"webhook_id": str(webhook.id), "user_id": user_id}
            )

            return webhook

        except Exception as e:
            logger.error(f"Failed to store webhook: {str(e)}", exc_info=True)
            raise WebhookStorageError(f"Failed to process webhook: {str(e)}")

    async def retrieve_webhooks(self, user_id: str) -> WebhookListResponse:
        """Retrieve stored webhooks for user."""
        try:
            data = await self.repo.get_webhooks(user_id)
            webhooks = [StoredWebhook(**item) for item in data]

            return WebhookListResponse(
                webhooks=webhooks,
                total_count=len(webhooks),
                user_id=user_id,
            )
        except Exception as e:
            logger.error(f"Failed to retrieve webhooks: {str(e)}")
            raise WebhookStorageError("Failed to retrieve webhook history")

    async def clear_webhooks(self, user_id: str) -> dict:
        """Clear all test webhooks for user."""
        try:
            count = await self.repo.clear_webhooks(user_id)
            logger.info(f"Cleared {count} test webhooks for user {user_id[:8]}...")
            return {
                "message": "Test webhooks cleared successfully",
                "deleted_count": count,
                "user_id": user_id,
            }
        except Exception as e:
            logger.error(f"Failed to clear webhooks: {str(e)}")
            raise WebhookStorageError("Failed to clear webhook history")
```

### 5. API Routes

```python
# app/api/v1/webhooks.py
import logging
from typing import Annotated

from fastapi import APIRouter, Depends, Request, Response, status
from fastapi.responses import JSONResponse

from app.core.exceptions import WebhookStorageError
from app.dependencies.auth import get_current_user_id
from app.dependencies.database import (
    WebhookTestRepository,
    get_webhook_repository,
)
from app.models.webhook import StoredWebhook, WebhookListResponse
from app.services.webhook_test import WebhookTestService

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhooks", tags=["webhooks"])

async def get_webhook_service(
    repo: Annotated[WebhookTestRepository, Depends(get_webhook_repository)]
) -> WebhookTestService:
    """Dependency factory for webhook service."""
    return WebhookTestService(repository=repo)

@router.post(
    "/test",
    response_model=StoredWebhook,
    status_code=status.HTTP_201_CREATED,
    summary="Receive and store test webhook",
    description="""
    Echo endpoint that stores the incoming webhook payload for debugging.
    Stores last 10 webhooks per user (FIFO eviction).
    Returns the stored record with metadata and assigned ID.
    """,
    responses={
        201: {"description": "Webhook stored successfully"},
        401: {"description": "Authentication required"},
        503: {"description": "Storage service unavailable"},
    },
)
async def receive_test_webhook(
    request: Request,
    user_id: Annotated[str, Depends(get_current_user_id)],
    service: Annotated[WebhookTestService, Depends(get_webhook_service)],
) -> StoredWebhook:
    """
    Process incoming webhook request and store for debugging.

    Captures headers, body, query params, and metadata for inspection.
    """
    # Read raw body for preservation
    body = await request.body()

    try:
        webhook = await service.store_incoming_webhook(
            user_id=user_id,
            request=request,
            raw_body=body,
        )
        return webhook

    except WebhookStorageError:
        raise
    except Exception as e:
        logger.error(f"Unexpected error in webhook endpoint: {e}", exc_info=True)
        raise WebhookStorageError("Internal server error processing webhook")

@router.get(
    "/test",
    response_model=WebhookListResponse,
    summary="List stored test webhooks",
    description="""
    Retrieve the last 10 stored webhooks for the authenticated user.
    Returns full payload, headers, and metadata for debugging.
    Ordered by receipt time (newest first).
    """,
    responses={
        200: {"description": "List of stored webhooks"},
        401: {"description": "Authentication required"},
        503: {"description": "Storage service unavailable"},
    },
)
async def list_test_webhooks(
    user_id: Annotated[str, Depends(get_current_user_id)],
    service: Annotated[WebhookTestService, Depends(get_webhook_service)],
) -> WebhookListResponse:
    """Retrieve stored webhooks for the current user."""
    return await service.retrieve_webhooks(user_id)

@router.delete(
    "/test",
    status_code=status.HTTP_200_OK,
    summary="Clear test webhooks",
    description="Delete all stored test webhooks for the authenticated user.",
    responses={
        200: {"description": "Webhooks cleared successfully"},
        401: {"description": "Authentication required"},
    },
)
async def clear_test_webhooks(
    user_id: Annotated[str, Depends(get_current_user_id)],
    service: Annotated[WebhookTestService, Depends(get_webhook_service)],
) -> dict:
    """Clear all test webhooks for the current user."""
    return await service.clear_webhooks(user_id)
```

### 6. Main Application Setup

```python
# app/main.py
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.api.v1.webhooks import router as webhooks_router
from app.core.config import get_settings

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",


Response:
Here is the production-ready implementation for **TW-INFRA-36: Admin Tenant Impersonation**.

## 1. Database Schema (Migration)

```sql
-- migrations/003_add_audit_logs_and_impersonation.sql

-- Audit logs table for compliance and security tracking
CREATE TABLE IF NOT EXISTS audit_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    action VARCHAR(50) NOT NULL,  -- IMPERSONATION_START, IMPERSONATION_END, DATA_ACCESS, etc.
    target_tenant_id UUID,
    details JSONB DEFAULT '{}',
    ip_address INET,
    user_agent TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

    CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    CONSTRAINT fk_audit_target_tenant FOREIGN KEY (target_tenant_id) REFERENCES tenants(id) ON DELETE SET NULL
);

-- Index for fast querying of impersonation sessions
CREATE INDEX idx_audit_logs_user_action ON audit_logs(user_id, action, created_at DESC);
CREATE INDEX idx_audit_logs_target_tenant ON audit_logs(target_tenant_id, created_at DESC);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);

-- Add impersonation flag to existing sessions or handle via Redis (recommended)
-- Note: We use Redis for active session storage due to TTL requirements
```

## 2. Pydantic Schemas (Pydantic v2)

```python
# schemas/impersonation.py
from datetime import datetime
from typing import Optional, Dict, Any
from uuid import UUID
from pydantic import BaseModel, Field, ConfigDict

class ImpersonationStartResponse(BaseModel):
    """Response when starting impersonation session."""
    model_config = ConfigDict(json_schema_extra={
        "example": {
            "success": True,
            "message": "Now impersonating tenant: Acme Corp",
            "impersonated_tenant_id": "550e8400-e29b-41d4-a716-446655440000",
            "session_id": "impersonation:123e4567-e89b-12d3-a456-426614174000",
            "expires_at": "2024-01-15T10:30:00Z",
            "warning": "AUDIT: All actions will be logged"
        }
    })

    success: bool = True
    message: str
    impersonated_tenant_id: UUID
    session_id: str
    expires_at: datetime
    warning: str = "AUDIT: All actions will be logged"

class ImpersonationStatus(BaseModel):
    """Current impersonation status for the authenticated user."""
    model_config = ConfigDict(json_schema_extra={
        "example": {
            "is_impersonating": True,
            "impersonated_tenant": {
                "id": "550e8400-e29b-41d4-a716-446655440000",
                "name": "Acme Corp",
                "plan": "enterprise"
            },
            "started_at": "2024-01-15T09:30:00Z",
            "warning": "AUDIT MODE: All actions are being logged"
        }
    })

    is_impersonating: bool
    impersonated_tenant: Optional[Dict[str, Any]] = None
    started_at: Optional[datetime] = None
    original_tenant_id: Optional[UUID] = None
    warning: Optional[str] = None

class ImpersonationEndResponse(BaseModel):
    """Response when ending impersonation."""
    success: bool
    message: str
    returned_to_tenant: UUID
    audit_note: str = "Session logged for compliance"

class AuditLogEntry(BaseModel):
    """Audit log entry for impersonation actions."""
    id: UUID
    user_id: UUID
    action: str
    target_tenant_id: Optional[UUID]
    details: Dict[str, Any]
    ip_address: Optional[str]
    created_at: datetime
```

## 3. Service Layer

```python
# services/impersonation.py
import json
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from uuid import UUID

import redis.asyncio as redis
from asyncpg import Pool

logger = logging.getLogger(__name__)

class ImpersonationError(Exception):
    """Custom exception for impersonation-related errors."""
    pass

class Imp


Response:
 Here is the production-ready implementation for **TW-INFRA-37** following FastAPI best practices, with distributed Redis sliding-window rate limiting, tier-based limits, and comprehensive header injection.

## Project Structure

```
sunaiva-backend/
├── app/
│   ├── core/
│   │   ├── config.py
│   │   ├── constants.py
│   │   └── exceptions.py
│   ├── models/
│   │   └── rate_limit.py
│   ├── services/
│   │   ├── redis.py
│   │   └── rate_limiter.py
│   ├── dependencies/
│   │   └── rate_limit.py
│   ├── middleware/
│   │   └── rate_limit_headers.py
│   ├── api/
│   │   └── errors.py
│   └── main.py
└── tests/
    ├── unit/
    │   └── test_rate_limiter.py
    └── integration/
        └── test_rate_limit_headers.py
```

## 1. Core Configuration & Models

```python
# app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
from typing import Optional

class Settings(BaseSettings):
    """Application configuration with environment variable support."""
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

    # Redis Configuration for distributed rate limiting
    REDIS_URL: str = "redis://localhost:6379/0"
    REDIS_POOL_SIZE: int = 20

    # Default rate limit window (seconds)
    RATE_LIMIT_WINDOW: int = 60

    # API Configuration
    API_V1_PREFIX: str = "/api/v1"

    # Elestio VPS Specific
    TRUSTED_PROXIES: Optional[str] = None

@lru_cache
def get_settings() -> Settings:
    return Settings()

settings = get_settings()
```

```python
# app/core/constants.py
from enum import Enum
from pydantic import BaseModel

class PlanTier(str, Enum):
    """Subscription tiers for Sunaiva."""
    FREE = "free"
    BASIC = "basic"
    PRO = "pro"
    ENTERPRISE = "enterprise"

class RateLimitConfig(BaseModel):
    """Configuration for rate limiting per tier."""
    requests: int
    window_seconds: int
    burst_allowance: int = 0  # Optional: Token bucket burst capacity

# Tier-based rate limits (requests per window)
TIER_LIMITS: dict[PlanTier, RateLimitConfig] = {
    PlanTier.FREE: RateLimitConfig(requests=60, window_seconds=60),      # 1 req/sec avg
    PlanTier.BASIC: RateLimitConfig(requests=300, window_seconds=60),    # 5 req/sec avg
    PlanTier.PRO: RateLimitConfig(requests=1000, window_seconds=60),   # ~16 req/sec avg
    PlanTier.ENTERPRISE: RateLimitConfig(requests=10000, window_seconds=60),  # Custom
}
```

```python
# app/core/exceptions.py
from fastapi import HTTPException
from typing import Any

class RateLimitExceeded(HTTPException):
    """
    Custom exception for rate limiting with structured error details.
    Automatically includes Retry-After header for 429 responses.
    """

    def __init__(
        self,
        limit: int,
        remaining: int,
        reset_at: int,
        retry_after: int,
        tier: str = "unknown"
    ) -> None:
        self.limit = limit
        self.remaining = remaining
        self.reset_at = reset_at
        self.retry_after = retry_after
        self.tier = tier

        super().__init__(
            status_code=429,
            detail={
                "error": "rate_limit_exceeded",
                "message": "API rate limit exceeded. Please implement exponential backoff.",
                "tier": tier,
                "limit": limit,
                "remaining": remaining,
                "reset_at": reset_at,  # Unix timestamp
                "retry_after_seconds": retry_after,
                "documentation": "https://docs.sunaiva.com/rate-limits"
            },
            headers={
                "Retry-After": str(retry_after),
                "X-RateLimit-Limit": str(limit),
                "X-RateLimit-Remaining": str(remaining),
                "X-RateLimit-Reset": str(reset_at)
            }
        )
```

```python
# app/models/rate_limit.py
from pydantic import BaseModel, Field
from typing import Optional

class RateLimitInfo(BaseModel):
    """Model representing current rate limit status for a client."""
    tier: str = Field(..., description="Subscription tier identifier")
    limit: int = Field(..., description="Maximum requests allowed in window")
    remaining: int = Field(..., ge=0, description="Remaining requests in current window")
    reset_at: int = Field(..., description="Unix timestamp when limit resets")
    window: int = Field(..., description="Window duration in seconds")

    class Config:
        json_schema_extra = {
            "example": {
                "tier": "pro",
                "limit": 1000,
                "remaining": 999,
                "reset_at": 1704067200,
                "window": 60
            }
        }

class RateLimitCheckResult(BaseModel):
    """Internal model for rate limiter service results."""
    allowed: bool
    limit: int
    remaining: int
    reset_at: int
    retry_after: Optional[int] = None
```

## 2. Redis Service & Rate Limiter

```python
# app/services/redis.py
import redis.asyncio as redis
from app.core.config import settings
from typing import Optional
import logging

logger = logging.getLogger(__name__)

class RedisManager:
    """Manages Redis connection pool for async operations."""

    _instance: Optional[redis.Redis] = None

    @classmethod
    async def get_redis(cls) -> redis.Redis:
        """Get or create Redis connection singleton."""
        if cls._instance is None:
            try:
                cls._instance = redis.from_url(
                    settings.REDIS_URL,
                    encoding="utf-8",
                    decode_responses=True,
                    max_connections=settings.REDIS_POOL_SIZE,
                    socket_connect_timeout=5,
                    socket_keepalive=True,
                    health_check_interval=30
                )
                # Verify connection
                await cls._instance.ping()
                logger.info("Redis connection established for rate limiting")
            except Exception as e:
                logger.error(f"Failed to connect to Redis: {e}")
                raise
        return cls._instance

    @classmethod
    async def close(cls) -> None:
        """Close Redis connection gracefully."""
        if cls._


Response:
 Here's the production-ready implementation for the Conversation Tagging System following Clean Architecture principles:

## 1. Database Schema (`migrations/003_conversation_tags.sql`)

```sql
-- Tags master table with categorization
CREATE TABLE tags (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL CHECK (name ~ '^[a-z0-9-]+$'), -- kebab-case only
    display_name VARCHAR(100) NOT NULL,
    category VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (category IN ('user', 'auto', 'system')),
    color VARCHAR(7), -- hex color for UI
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Junction table for many-to-many relationship
CREATE TABLE conversation_tags (
    conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
    tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
    added_by VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (added_by IN ('user', 'ai', 'system')),
    confidence_score DECIMAL(3,2), -- For AI-generated tags (0.00-1.00)
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (conversation_id, tag_id)
);

-- Indexes for performance
CREATE INDEX idx_conversation_tags_conversation_id ON conversation_tags(conversation_id);
CREATE INDEX idx_conversation_tags_tag_id ON conversation_tags(tag_id);
CREATE INDEX idx_conversation_tags_created_at ON conversation_tags(created_at DESC);
CREATE INDEX idx_tags_category ON tags(category);
CREATE INDEX idx_tags_name ON tags(name);

-- Seed auto-tags
INSERT INTO tags (name, display_name, category, color) VALUES
    ('urgent', 'Urgent', 'auto', '#EF4444'),
    ('follow-up', 'Follow Up', 'auto', '#F59E0B'),
    ('complaint', 'Complaint', 'auto', '#DC2626'),
    ('booking', 'Booking', 'auto', '#10B981'),
    ('inquiry', 'Inquiry', 'auto', '#3B82F6');
```

## 2. Domain Models (`src/models/tag.py`)

```python
"""Pydantic models for conversation tagging system."""
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import List, Optional
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field, field_validator

class TagCategory(str, Enum):
    """Tag classification categories."""
    USER = "user"
    AUTO = "auto"
    SYSTEM = "system"

class TagSource(str, Enum):
    """Source of tag assignment."""
    USER = "user"
    AI = "ai"
    SYSTEM = "system"

class AutoTagType(str, Enum):
    """Predefined AI analysis tags."""
    URGENT = "urgent"
    FOLLOW_UP = "follow-up"
    COMPLAINT = "complaint"
    BOOKING = "booking"
    INQUIRY = "inquiry"

class TagBase(BaseModel):
    """Base tag model."""
    name: str = Field(..., min_length=1, max_length=50, pattern=r'^[a-z0-9-]+$')
    display_name: str = Field(..., min_length=1, max_length=100)
    category: TagCategory = TagCategory.USER
    color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')

    @field_validator('name')
    @classmethod
    def normalize_name(cls, v: str) -> str:
        """Normalize tag names to kebab-case."""
        return v.lower().strip()

class TagCreate(TagBase):
    """Model for creating new tags."""
    pass

class TagUpdate(BaseModel):
    """Model for updating tags."""
    display_name: Optional[str] = Field(None, min_length=1, max_length=100)
    color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')

class TagInDB(TagBase):
    """Tag model as stored in database."""
    model_config = ConfigDict(from_attributes=True)

    id: int
    created_at: datetime
    updated_at: datetime

class ConversationTag(BaseModel):
    """Tag attached to a specific conversation."""
    model_config = ConfigDict(from_attributes=True)

    tag_id: int
    name: str
    display_name: str
    color: Optional[str]
    category: TagCategory
    added_by: TagSource
    confidence_score: Optional[Decimal] = Field(None, ge=0, le=1)
    created_at: datetime

class TagAddRequest(BaseModel):
    """Request model for adding tags to conversation."""
    tags: List[str] = Field(..., min_length=1, max_length=50)

    @field_validator('tags')
    @classmethod
    def validate_tags(cls, v: List[str]) -> List[str]:
        """Normalize and deduplicate tags."""
        normalized = [tag.lower().strip() for tag in v]
        return list(dict.fromkeys(normalized))  # Remove duplicates while preserving order

class TagRemoveRequest(BaseModel):
    """Request model for removing tags."""
    tag: str = Field(..., min_length=1, max_length=50)

    @field_validator('tag')
    @classmethod
    def normalize_tag(cls, v: str) -> str:
        """Normalize tag name."""
        return v.lower().strip()

class ConversationFilter(BaseModel):
    """Filter model for conversations."""
    tags: Optional[List[str]] = None
    tag_category: Optional[TagCategory] = None
    match_all: bool = False  # If true, conversation must have ALL tags (AND), else ANY (OR)

class AutoTagResult(BaseModel):
    """Result from AI auto-tagging analysis."""
    tag: AutoTagType
    confidence: Decimal = Field(..., ge=0, le=1)
    reasoning: Optional[str] = None
```

## 3. Repository Layer (`src/repositories/tag_repository.py`)

```python
"""Database repository for tag operations."""
from datetime import datetime
from decimal import Decimal
from typing import List, Optional, Tuple
from uuid import UUID

import asyncpg
from asyncpg import Connection

from src.models.tag import (
    AutoTagResult,
    ConversationTag,
    TagCategory,
    TagInDB,
    TagSource,
)
from src.core.exceptions import DatabaseError, NotFoundError, ConflictError

class TagRepository:
    """Repository for tag database operations."""

    def __init__(self, conn: Connection):
        self.conn = conn

    async def get_by_name(self, name: str) -> Optional[TagInDB]:
        """Fetch tag by normalized name."""
        query = """
            SELECT id, name, display_name, category, color, created_at, updated_at
            FROM tags
            WHERE name = $1
        """
        try:
            row = await self.conn.fetchrow(query, name)
            return TagInDB.model_validate(dict(row)) if row else None
        except asyncpg.PostgresError as e:
            raise DatabaseError(f"Failed to fetch tag: {e}") from e

    async def get_or_create(
        self,
        name: str,
        display_name: str,
        category: TagCategory = TagCategory.USER
    ) -> TagInDB:
        """Get existing tag or create new one."""
        existing = await self.get_by_name(name)
        if existing:
            return existing

        query = """
            INSERT INTO tags (name, display_name, category)
            VALUES ($1, $2, $3)
            ON CONFLICT (name) DO UPDATE
            SET display_name = EXCLUDED.display_name
            RETURNING id, name, display_name, category, color, created_at, updated_at
        """
        try:
            row = await self.conn.fetchrow(query, name, display_name, category.value)
            return TagInDB.model_validate(dict(row))
        except asyncpg.PostgresError as e:
            raise DatabaseError(f"Failed to create tag: {e}") from e

    async def add_tags_to_conversation(
        self,
        conversation_id: UUID,
        tag_names: List[str],
        added_by: TagSource = TagSource.USER,
        confidence_scores: Optional[dict] = None
    ) -> List[ConversationTag]:
        """Add multiple tags to a conversation."""
        if not tag_names:
            return []

        confidence_scores = confidence_scores or {}

        # Use transaction to ensure atomicity
        async with self.conn.transaction():
            results = []

            for tag_name in tag_names:
                # Get or create tag
                tag = await self.get_or_create(
                    tag_name,
                    tag_name.replace('-', ' ').title(),
                    TagCategory.USER if added_by == TagSource.USER else TagCategory.AUTO
                )

                confidence = confidence_scores.get(tag_name)

                query = """
                    INSERT INTO conversation_tags
                        (conversation_id, tag_id, added_by, confidence_score)
                    VALUES ($1, $2, $3, $4)
                    ON CONFLICT (conversation_id, tag_id) DO UPDATE
                    SET added_by = EXCLUDED.added_by,
                        confidence_score = EXCLUDED.confidence_score,
                        created_at = CURRENT_TIMESTAMP
                    RETURNING
                        tag_id,
                        $5 as name,
                        $6 as display_name,
                        $7 as color,
                        $8 as category,
                        added_by,
                        confidence_score,
                        created_at
                """

                try:
                    row = await self.conn.fetchrow(
                        query,
                        conversation_id,
                        tag.id,
                        added_by.value,
                        confidence,
                        tag.name,
                        tag.display_name,
                        tag.color,
                        tag.category.value
                    )
                    results.append(ConversationTag.model_validate(dict(row)))
                except asyncpg.UniqueViolationError:
                    continue  # Already exists, skip
                except asyncpg.PostgresError as e:
                    raise DatabaseError(f"Failed to add tag {tag_name}: {e}") from e

            return results

    async def remove_tag_from_conversation(
        self,
        conversation_id: UUID,
        tag_name: str
    ) -> bool:
        """Remove a specific tag from conversation."""
        # First get tag_id
        tag = await self.get_by_name(tag_name)
        if not tag:
            raise NotFoundError(f"Tag '{tag_name}' not found")

        query = """
            DELETE FROM conversation_tags
            WHERE conversation_id = $1 AND tag_id = $2
            RETURNING conversation_id
        """
        try:
            result = await self.conn.fetchval(query, conversation_id, tag.id)
            if result is None:
                raise NotFoundError(
                    f"Tag '{tag_name}' not found on conversation {conversation_id}"
                )
            return True
        except asyncpg.PostgresError as e:
            raise DatabaseError(f"Failed to remove tag: {e}") from e

    async def get_conversation_tags(
        self,
        conversation_id: UUID,
        category: Optional[TagCategory] = None
    ) -> List[ConversationTag]:
        """Fetch all tags for a conversation."""
        params = [conversation_id]
        category_filter = ""

        if category:
            category_filter = "AND t.category = $2"
            params.append(category.value)

        query = f"""
            SELECT
                ct.tag_id,
                t.name,
                t.display_name,
                t.color,
                t.category,
                ct.added_by,
                ct.confidence_score,
                ct.created_at
            FROM conversation_tags ct
            JOIN tags t ON ct.tag_id = t.id
            WHERE ct.conversation_id = $1 {category_filter}
            ORDER BY ct.created_at DESC
        """

        try:
            rows = await self.conn.fetch(query, *params)
            return [ConversationTag.model_validate(dict(row)) for row in rows]
        except asyncpg.PostgresError as e:
            raise DatabaseError(f"Failed to fetch conversation tags: {e}") from e

    async def filter_conversations_by_tags(
        self,
        tag_names: List[str],
        match_all: bool = False,
        limit: int = 50,
        offset: int = 0
    ) -> Tuple[List[UUID], int]:
        """
        Filter conversations by tags.

        Args:
            tag_names: List of tag names to filter by
            match_all: If True, conversation must have ALL tags (AND), else ANY (OR)
            limit: Pagination limit
            offset: Pagination offset

        Returns:
            Tuple of (conversation_ids, total_count)
        """
        if not tag_names:
            return [], 0

        # Get tag IDs first
        tag_placeholders = ', '.join(f'${i+2}' for i in range(len(tag_names)))
        tag_query = f"SELECT id FROM tags WHERE name = ANY(ARRAY[{tag_placeholders}])"

        try:
            tag_rows = await self.conn.fetch(tag_query, *tag_names)
            tag_ids = [row['id'] for row in tag_rows]

            if not tag_ids:
                return [], 0

            if match_all:
                # AND logic - conversation must have all specified tags
                query = """
                    SELECT ct.conversation_id, COUNT(*) OVER() as total_count
                    FROM conversation_tags ct
                    WHERE ct.tag_id = ANY($1)
                    GROUP BY ct.conversation_id
                    HAVING COUNT(DISTINCT ct.tag_id) = $2
                    ORDER BY MAX(ct.created_at) DESC
                    LIMIT $3 OFFSET $4
                """
                rows = await self.conn.fetch(query, tag_ids, len(tag_ids), limit, offset)
            else:
                # OR logic - conversation has any of the tags
                query = """
                    SELECT DISTINCT ON (ct.conversation_id)
                        ct.conversation_id,
                        COUNT(*) OVER() as total_count
                    FROM conversation_tags ct
                    WHERE ct.tag_id = ANY($1)
                    ORDER BY ct.conversation_id, ct.created_at DESC
                    LIMIT $2 OFFSET $3
                """
                rows = await self.conn.fetch(query, tag_ids, limit, offset)

            if not rows:
                return [], 0

            conversation_ids = [row['conversation_id'] for row in rows]
            total_count = rows[0]['total_count'] if rows else 0

            return conversation_ids, total_count

        except asyncpg.PostgresError as e:
            raise DatabaseError(f"Failed to filter conversations: {e}") from e

    async def apply_auto_tags(
        self,
        conversation_id: UUID,
        auto_tags: List[AutoTagResult]
    ) -> List[ConversationTag]:
        """Apply AI-generated tags to conversation."""
        if not auto_tags:
            return []

        tag_names = [tag.tag.value for tag in auto_tags]
        confidence_map = {tag.tag.value: tag.confidence for tag in auto_tags}

        return await self.add_tags_to_conversation(
            conversation_id=conversation_id,
            tag_names=tag_names,
            added_by=TagSource.AI,
            confidence_scores=confidence_map
        )

    async def get_tag_stats(self, conversation_id: UUID) -> dict:
        """Get tagging statistics for a conversation."""
        query = """
            SELECT
                COUNT(*) as total_tags,
                COUNT(*) FILTER (WHERE added_by = 'ai') as auto_tags,
                COUNT(*) FILTER (WHERE added_by = 'user') as user_tags,
                MAX(created_at) as last_tagged_at
            FROM conversation_tags
            WHERE conversation_id = $1
        """
        try:
            row = await self.conn.fetchrow(query, conversation_id)
            return {
                "total_tags": row['total_tags'],
                "auto_tags": row['auto_tags'],
                "user_tags": row['user_tags'],
                "last_tagged_at": row['last_tagged_at']
            }
        except asyncpg.PostgresError as e:
            raise DatabaseError(f"Failed to get tag stats: {e}") from e
```

## 4. AI Service (`src/services/ai_tagging_service.py`)

```python
"""AI-powered auto-tagging service."""
import re
from decimal import Decimal
from typing import List, Optional

from src.models.tag import AutoTagResult, AutoTagType

class AITaggingService:
    """Service for analyzing conversations and suggesting tags."""

    # Keyword patterns for rule-based tagging (fallback when AI is unavailable)
    TAG_PATTERNS = {
        AutoTagType.URGENT: [
            r'\b(urgent|emergency|asap|immediately|critical)\b',
            r'\b(hurry|quick|rush|deadline|expired)\b',
        ],
        AutoTagType.COMPLAINT: [
            r'\b(complaint|unhappy|disappointed|terrible|awful)\b',
            r'\b(refund|cancel.*subscription|speak.*manager)\b',
            r'\b(not working|broken|frustrated|angry)\b',
        ],
        AutoTagType.BOOKING: [
            r'\b(book|reservation|appointment|schedule)\b',
            r'\b(available.*date|time slot|check.*availability)\b',
        ],
        AutoTagType.FOLLOW_UP: [
            r'\b(follow up|followup|get back to me|call me back)\b',
            r'\b(next week|tomorrow|later|soon|check.*later)\b',
        ],
        AutoTagType.INQUIRY: [
            r'\b(inquiry|question|wondering|curious about|information)\b',
            r'\b(how much|pricing|cost|price|quote)\b',
        ]
    }

    @classmethod
    async def analyze_conversation(
        cls,
        transcript: str,
        sentiment_score: Optional[float] = None
    ) -> List[AutoTagResult]:
        """
        Analyze conversation content and suggest tags.

        In production


Response:
 I'll implement the webhook payload signing infrastructure for Sunaiva with HMAC-SHA256, per-tenant secrets with encryption at rest, and secret rotation capabilities.

## 1. Database Schema & Models

```sql
-- migration_003_webhook_secrets.sql
CREATE TABLE webhook_secrets (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    secret_encrypted BYTEA NOT NULL,        -- Fernet encrypted
    secret_hash VARCHAR(64) NOT NULL,       -- Blake2b digest for integrity checks
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMPTZ,                 -- NULL = active, set during rotation
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    last_used_at TIMESTAMPTZ,
    rotated_by UUID REFERENCES users(id),

    CONSTRAINT unique_active_secret_per_tenant
        UNIQUE (tenant_id)
        WHERE is_active = TRUE AND expires_at IS NULL
);

CREATE INDEX idx_webhook_secrets_tenant ON webhook_secrets(tenant_id);
CREATE INDEX idx_webhook_secrets_active ON webhook_secrets(tenant_id, is_active, expires_at);
```

## 2. Core Cryptography & Signing Service

```python
# src/services/webhook_signing.py
"""
Webhook payload signing and verification service.
Implements HMAC-SHA256 with constant-time comparison and secret rotation.
"""

import hmac
import hashlib
import secrets
import time
import logging
from typing import Optional, Tuple, List
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass

from cryptography.fernet import Fernet, InvalidToken

from src.core.config import settings
from src.core.exceptions import (
    WebhookVerificationError,
    SecretRotationError,
    ConfigurationError
)

logger = logging.getLogger(__name__)

@dataclass(frozen=True)
class SignatureComponents:
    """Parsed webhook signature header components."""
    timestamp: int
    signatures: List[str]  # List of v1 signatures to try (current + grace period)

    @property
    def timestamp_datetime(self) -> datetime:
        return datetime.fromtimestamp(self.timestamp, tz=timezone.utc)

class WebhookSigningService:
    """
    HMAC-SHA256 webhook signing with Stripe-compatible signature scheme.

    Signature format: X-Sunaiva-Signature: t=<timestamp>,v1=<signature>
    Signed payload:   <timestamp>.<raw_body>
    """

    VERSION = "v1"
    HEADER_NAME = "X-Sunaiva-Signature"
    TOLERANCE_SECONDS = 300  # 5 minute tolerance for replay protection

    def __init__(self, encryption_key: Optional[str] = None):
        """
        Initialize with Fernet encryption key for secrets at rest.

        Args:
            encryption_key: Base64-encoded 32-byte key. Defaults to settings.WEBHOOK_SECRET_ENCRYPTION_KEY.
        """
        key = encryption_key or settings.WEBHOOK_SECRET_ENCRYPTION_KEY
        if not key:
            raise ConfigurationError("WEBHOOK_SECRET_ENCRYPTION_KEY not configured")
        self._fernet = Fernet(key.encode())

    def generate_secret(self) -> str:
        """
        Generate cryptographically secure webhook secret.

        Returns:
            32-byte URL-safe base64-encoded secret (43 chars)
        """
        # 32 bytes = 256 bits of entropy
        return secrets.token_urlsafe(32)

    def encrypt_secret(self, plaintext: str) -> Tuple[bytes, str]:
        """
        Encrypt secret for database storage and compute hash for integrity.

        Args:
            plaintext: Raw webhook secret

        Returns:
            Tuple of (encrypted_bytes, hash_hex)
        """
        encrypted = self._fernet.encrypt(plaintext.encode())
        # Hash for integrity verification without decryption
        hash_digest = hashlib.blake2b(encrypted, key=settings.SECRET_HASH_KEY.encode()).hexdigest()
        return encrypted, hash_digest

    def decrypt_secret(self, encrypted: bytes) -> str:
        """
        Decrypt secret from database storage.

        Args:
            encrypted: Fernet-encrypted bytes

        Returns:
            Decrypted plaintext secret

        Raises:
            WebhookVerificationError: If decryption fails (tampering/corruption)
        """
        try:
            return self._fernet.decrypt(encrypted).decode()
        except InvalidToken as e:
            logger.error("Webhook secret decryption failed - possible tampering")
            raise WebhookVerificationError("Secret decryption failed") from e

    def compute_signature(self, payload: bytes, secret: str, timestamp: Optional[int] = None) -> Tuple[int, str]:
        """
        Compute HMAC-SHA256 signature for payload.

        Args:
            payload: Raw request body bytes
            secret: Webhook secret
            timestamp: Unix timestamp (default: current time)

        Returns:
            Tuple of (timestamp, signature_hex)
        """
        timestamp = timestamp or int(time.time())
        # Stripe-style signed payload: timestamp.payload
        signed_payload = f"{timestamp}.".encode() + payload

        signature = hmac.new(
            key=secret.encode(),
            msg=signed_payload,
            digestmod=hashlib.sha256
        ).hexdigest()

        return timestamp, signature

    def build_signature_header(self, payload: bytes, secret: str) -> str:
        """
        Build complete signature header value.

        Args:
            payload: Raw request body
            secret: Webhook secret

        Returns:
            Header value: "t=1234567890,v1=abc123..."
        """
        timestamp, signature = self.compute_signature(payload, secret)
        return f"t={timestamp},{self.VERSION}={signature}"

    def verify_signature(
        self,
        payload: bytes,
        signature_header: str,
        secrets: List[str],
        tolerance_seconds: Optional[int] = None
    ) -> bool:
        """
        Verify webhook signature against one or more secrets (for rotation grace period).

        Uses constant-time comparison to prevent timing attacks.

        Args:
            payload: Raw request body bytes
            signature_header: Value from X-Sunaiva-Signature header
            secrets: List of secrets to try (active + rotating)
            tolerance_seconds: Max age of timestamp (default: 300s)

        Returns:
            True if signature valid

        Raises:
            WebhookVerificationError: If signature invalid, expired, or malformed
        """
        tolerance = tolerance_seconds or self.TOLERANCE_SECONDS
        components = self._parse_header(signature_header)

        # Check timestamp for replay attacks
        now = int(time.time())
        if abs(now - components.timestamp) > tolerance:
            raise WebhookVerificationError(
                f"Timestamp too old or too new (tolerance: {tolerance}s)"
            )

        # Try each secret (active + grace period secrets)
        for secret in secrets:
            expected_sig = self._compute_expected_signature(payload, secret, components.timestamp)

            # Constant-time comparison to prevent timing attacks
            if hmac.compare_digest(expected_sig, self._extract_v1_signature(components.signatures)):
                return True

        raise WebhookVerificationError("No matching signature found")

    def _parse_header(self, header: str) -> SignatureComponents:
        """Parse signature header into components."""
        try:
            items = {}
            for item in header.split(","):
                key, value = item.strip().split("=", 1)
                items[key] = value

            if "t" not in items:
                raise ValueError("Missing timestamp")

            timestamp = int(items["t"])

            # Collect all v1 signatures (there might be multiple during rotation)
            signatures = [v for k, v in items.items() if k.startswith("v")]

            if not signatures:
                raise ValueError("No signature versions found")

            return SignatureComponents(timestamp=timestamp, signatures=signatures)

        except ValueError as e:
            raise WebhookVerificationError(f"Invalid signature header format: {e}")

    def _compute_expected_signature(self, payload: bytes, secret: str, timestamp: int) -> str:
        """Compute expected signature for comparison."""
        signed_payload = f"{timestamp}.".encode() + payload
        return hmac.new(
            secret.encode(),
            signed_payload,
            hashlib.sha256
        ).hexdigest()

    def _extract_v1_signature(self, signatures: List[str]) -> str:
        """Extract v1 signature from list (assumes first is v1 for now)."""
        return signatures[0] if signatures else ""
```

## 3. Database Repository Layer

```python
# src/repositories/webhook_secret_repo.py
"""
Async repository for webhook secret lifecycle management with rotation support.
"""

from typing import Optional, List
from datetime import datetime, timedelta
import asyncpg
from uuid import UUID

from src.services.webhook_signing import WebhookSigningService
from src.core.exceptions import DatabaseError, ResourceNotFoundError

class WebhookSecretRepository:
    """PostgreSQL repository for encrypted webhook secrets."""

    def __init__(self, pool: asyncpg.Pool, signing_service: WebhookSigningService):
        self._pool = pool
        self._signing = signing_service

    async def get_active_secret(self, tenant_id: UUID) -> Optional[bytes]:
        """
        Retrieve active (non-expired) encrypted secret for tenant.

        Returns:
            Encrypted secret bytes or None if not found
        """
        async with self._pool.acquire() as conn:
            row = await conn.fetchrow(
                """
                SELECT secret_encrypted
                FROM webhook_secrets
                WHERE tenant_id = $1
                  AND is_active = TRUE
                  AND (expires_at IS NULL OR expires_at > NOW())
                ORDER BY created_at DESC
                LIMIT 1
                """,
                tenant_id
            )
            return row["secret_encrypted"] if row else None

    async def get_secrets_for_verification(self, tenant_id: UUID) -> List[str]:
        """
        Get all valid secrets for signature verification including grace period secrets.

        Returns:
            List of plaintext secrets (active + rotating)
        """
        async with self._pool.acquire() as conn:
            rows = await conn.fetch(
                """
                SELECT secret_encrypted
                FROM webhook_secrets
                WHERE tenant_id = $1
                  AND is_active = TRUE
                  AND (expires_at IS NULL OR expires_at > NOW())
                ORDER BY created_at DESC
                """,
                tenant_id
            )

            secrets = []
            for row in rows:
                try:
                    plaintext = self._signing.decrypt_secret(row["secret_encrypted"])
                    secrets.append(plaintext)
                except Exception as e:
                    # Log but don't fail - one bad secret shouldn't break verification
                    continue

            return secrets

    async def create_secret(
        self,
        tenant_id: UUID,
        rotated_by: Optional[UUID] = None,
        expires_in: Optional[timedelta] = None
    ) -> str:
        """
        Create new webhook secret for tenant.

        If an active secret exists, it will be expired (rotation).

        Args:
            tenant_id: Organization tenant ID
            rotated_by: User ID performing rotation
            expires_in: If set, mark as expiring (for grace period during rotation)

        Returns:
            Plaintext secret (shown only once)
        """
        plaintext = self._signing.generate_secret()
        encrypted, hash_digest = self._signing.encrypt_secret(plaintext)

        expires_at = None
        if expires_in:
            expires_at = datetime.utcnow() + expires_in

        async with self._pool.acquire() as conn:
            async with conn.transaction():
                # If there's an active secret, expire it (rotation)
                await conn.execute(
                    """
                    UPDATE webhook_secrets
                    SET expires_at = NOW() + INTERVAL '24 hours',
                        is_active = FALSE
                    WHERE tenant_id = $1
                      AND is_active = TRUE
                      AND expires_at IS NULL
                    """,
                    tenant_id
                )

                # Insert new secret
                await conn.execute(
                    """
                    INSERT INTO webhook_secrets
                        (tenant_id, secret_encrypted, secret_hash, expires_at, rotated_by)
                    VALUES ($1, $2, $3, $4, $5)
                    """,
                    tenant_id, encrypted, hash_digest, expires_at, rotated_by
                )

        return plaintext

    async def rotate_secret(self, tenant_id: UUID, rotated_by: UUID) -> str:
        """
        Rotate secret with 24-hour grace period.

        Old secret remains valid for 24 hours to prevent dropped webhooks
        during consumer transition.

        Returns:
            New plaintext secret
        """
        # Create new secret, expire old one with 24h grace
        return await self.create_secret(
            tenant_id=tenant_id,
            rotated_by=rotated_by,
            expires_in=timedelta(hours=24)
        )

    async def revoke_all_secrets(self, tenant_id: UUID) -> int:
        """Immediately revoke all secrets for tenant (security incident)."""
        async with self._pool.acquire() as conn:
            result = await conn.execute(
                """
                UPDATE webhook_secrets
                SET is_active = FALSE, expires_at = NOW()
                WHERE tenant_id = $1 AND is_active = TRUE
                """,
                tenant_id
            )
            return int(result.split()[-1])  # Parse "UPDATE n"
```

## 4. Webhook Delivery Service

```python
# src/services/webhook_delivery.py
"""
Asynchronous webhook delivery with signing, retries, and circuit breaking.
"""

import asyncio
from typing import Dict, Any, Optional
from datetime import datetime
import httpx
import json
import logging
from uuid import UUID

from src.services.webhook_signing import WebhookSigningService
from src.repositories.webhook_secret_repo import WebhookSecretRepository
from src.core.config import settings

logger = logging.getLogger(__name__)

class WebhookDeliveryService:
    """
    Deliver signed webhooks to customer endpoints with exponential backoff.
    """

    def __init__(
        self,
        signing_service: WebhookSigning