CatalyzeUpDocs
sondely / product

API Documentation & Keys

API Documentation & API Keys

Implementation Progress

Feature Status
OpenAPI spec auto-generated from FastAPI ✅ Done (at /docs)
Typed Pydantic models for all endpoints 🔲 Partial (some use raw dicts)
Organization-level API keys (create, revoke, list) 🔲 Not yet
API key authentication middleware 🔲 Not yet
Public API documentation page (styled, not raw Swagger) 🔲 Not yet
Rate limiting per API key 🔲 Not yet
API key usage tracking (last_used_at, request_count) 🔲 Not yet

Overview

Sondely needs a public API so organizations can integrate survey data into their own systems: dashboards, CRMs, grant reports, etc. API keys are scoped to organizations (not individual users), because the data belongs to the org.

Reference implementation: The IdeaPlaces Style Guide has a working API key system with hashed keys, prefix display, and bearer token auth. Study /home/chipdev/ideaplaces-meta/ideaplaces-styleguide/src/lib/auth/api-key.ts for the pattern.

API Key System

Key Structure

ip_live_<random_44_chars>

Prefix ip_live_ identifies the key as an Sondely production key. The full key is shown once at creation. Only the prefix + last 4 chars are stored for display (e.g., ip_live_****abcd).

Database Model

# New collection: api_keys
{
    "id": "key-cuid",
    "organization_id": "org-cuid",       # Key is scoped to an org
    "created_by_id": "user-cuid",        # Which admin created it
    "name": "Production Dashboard",       # User-friendly name
    "key_hash": "<argon2_hash>",          # Hashed key (never store plaintext)
    "prefix": "ip_live_",                 # Visible prefix for identification
    "last_four": "abcd",                  # Last 4 chars for display
    "last_used_at": null,                 # Updated on each API call
    "request_count": 0,                   # Total requests made
    "expires_at": null,                   # Optional expiry
    "revoked_at": null,                   # Soft-delete
    "created_at": "...",
}

API Key Endpoints

All require OWNER or ADMIN role in the organization.

POST   /api/v1/organizations/{org_id}/api-keys        # Create key (returns full key once)
GET    /api/v1/organizations/{org_id}/api-keys         # List keys (prefix + last4 only)
DELETE /api/v1/organizations/{org_id}/api-keys/{key_id} # Revoke key

Create response:

{
  "id": "key-cuid",
  "name": "Production Dashboard",
  "key": "ip_live_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd",
  "prefix": "ip_live_",
  "last_four": "abcd",
  "created_at": "2026-04-07T..."
}

The key field is only returned on creation. After that, only prefix + last_four are available.

Authentication Middleware

API calls authenticate via Authorization: Bearer ip_live_... header. The middleware:

  1. Extracts the bearer token
  2. Hashes it and looks up api_keys by key_hash
  3. Checks revoked_at is null and expires_at hasn't passed
  4. Updates last_used_at and increments request_count
  5. Resolves the organization_id and attaches it to the request context
  6. All subsequent queries are scoped to that org

If the header contains a JWT instead of an API key (starts with eyJ), fall back to the existing JWT auth. This means both auth methods work on all endpoints.

# backend/app/middleware/api_key.py

async def get_api_key_or_user(request: Request):
    """Resolve auth from API key OR JWT. Returns (org_id, user_id)."""
    auth = request.headers.get("Authorization", "")
    if auth.startswith("Bearer ip_live_"):
        # API key auth
        key = auth.replace("Bearer ", "")
        return await validate_api_key(key)
    else:
        # JWT auth (existing flow)
        return await get_current_active_user(request)

Public API Endpoints (API-key accessible)

These endpoints work with both JWT (logged-in user) and API key (programmatic access).

Surveys

GET    /api/v1/surveys                              # List org surveys
GET    /api/v1/surveys/{id}                          # Get survey details + questions
GET    /api/v1/surveys/{id}/responses                # Get all responses
GET    /api/v1/surveys/{id}/responses?email=x        # Filter by respondent
GET    /api/v1/surveys/{id}/compliance               # Compliance table data
GET    /api/v1/surveys/{id}/respondent/{email}/history # Respondent attempt history
GET    /api/v1/surveys/{id}/impact                   # Impact report data

Organizations

GET    /api/v1/organizations/{id}                    # Org details
GET    /api/v1/organizations/{id}/members            # Member list

Responses (for webhooks / integrations)

POST   /api/v1/surveys/{id}/respond                  # Submit a response programmatically

This allows external systems (Zapier, Make, custom integrations) to submit survey responses on behalf of respondents.

OpenAPI Spec

FastAPI already generates an OpenAPI spec at /api/v1/openapi.json. The current spec works but needs improvements:

Step 1: Add Pydantic response models to all endpoints

Many endpoints currently return raw dicts. Add proper response models:

# Example: compliance endpoint currently returns a raw list of dicts
# Should be:
class ComplianceMember(BaseModel):
    member_id: str
    email: str
    name: str
    status: str
    attempts: int
    last_taken: Optional[str]
    next_due: Optional[str]
    reminders_sent: int
    baseline_score: Optional[float]
    latest_score: Optional[float]
    delta: Optional[float]

@router.get("/{survey_id}/compliance", response_model=List[ComplianceMember])
async def get_survey_compliance(...):

This makes the OpenAPI spec accurate and enables auto-generated client SDKs.

Step 2: Add endpoint descriptions and examples

@router.get("/{survey_id}/responses",
    response_model=List[SurveyResponse],
    summary="Get survey responses",
    description="Returns all responses for a survey. API key users see all org responses. Respondents see only their own.",
    responses={
        200: {"description": "List of survey responses"},
        403: {"description": "Not authorized to view this survey"},
        404: {"description": "Survey not found"},
    }
)

Step 3: Styled API docs page

Replace the raw Swagger UI with a styled documentation page that matches the Sondely design. Options:

  • Scalar (https://scalar.com) provides a beautiful, branded API reference from the OpenAPI spec
  • Stoplight Elements is another option
  • Or build a custom page using the OpenAPI JSON

The docs page should be accessible at /api/docs and linked from the marketing site.

Implementation Order

  1. API key model + CRUD endpoints (backend, 1 PR)
  2. API key auth middleware (backend, same PR)
  3. Add response models to all endpoints (backend, 1 PR)
  4. Frontend: API key management page in org settings (1 PR)
  5. Styled API docs page (frontend, 1 PR)
  6. Rate limiting (backend, follow-up)

Frontend: API Key Management

Add a new tab "API Keys" in the organization detail page (visible to OWNER/ADMIN only).

┌──────────────────────────────────────────────────┐
│ CatalyzeUp Nonprofit                             │
│                                                  │
│ Members  Surveys  API Keys  Pending Invitations  │
│                                                  │
│ API Keys                        [Create API Key] │
│ ┌──────────────────────────────────────────────┐ │
│ │ Production Dashboard                         │ │
│ │ ip_live_****abcd                             │ │
│ │ Created Apr 7, 2026  Last used: 2 hours ago  │ │
│ │ 1,234 requests                    [Revoke]   │ │
│ └──────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Zapier Integration                           │ │
│ │ ip_live_****efgh                             │ │
│ │ Created Mar 15, 2026  Never used             │ │
│ │ 0 requests                        [Revoke]   │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘

On creation, show the full key in a modal with a copy button and a warning: "This key will only be shown once. Copy it now."