Skip to content

Specialties API

NOT BUILT — reconciled 2026-08-02

None of these endpoints exist. See index.md for the build status. Three conventions in the examples below predate the platform's own and are wrong:

  • PKs are UUIDs, never integers (P26 — UUIDv7). Every "id": 1 below should read as a UUID.
  • Authorization is per-org permission codes, never a role check. The "Roles Allowed" table has been corrected; if you find yourself comparing role codes in handler code, add a permission instead.
  • Pagination params are page + limit (apiquery.ParsePage), default ≤ 50, hard-capped at apiquery.MaxLimit (500) — not page_size with a max of 100.

All specialty endpoints are organization-scoped via Row-Level Security. Callers can only access specialties within their current organization; the org comes from the resolved hostname → X-Organization-ID header, and every per-org route group additionally mounts RequireURLOrgMatchesScope("id") (P47).

Authentication

All endpoints require an authenticated principal:

Authorization: Bearer <session token>

RBAC Summary

Authorization is by per-org permission code, checked by RequirePermission at the route and by current_app_has_permission(resource, action) inside the RLS policy. Superadmin (platform_memberships) bypasses.

OperationPermission
List/Readmembership-read (any principal with an active org membership)
Createspecialties.manage
Updatespecialties.manage
Deletespecialties.manage — pre-checked, 409 with in-use counts when specialists or offerings still reference the row

None of these permission rows are seeded today. This feature's migration (000040) seeds them and grants them to the system role templates.


GET /v1/specialties

List all specialties in the current organization.

Request

Query Parameters:

  • page (integer, optional) - Page number (default: 1)
  • page_size (integer, optional) - Items per page (default: 25, max: 100)
  • sort (string, optional) - Sort field (e.g., title, -created_at for descending)

Response: 200 OK

json
{
  "data": [
    {
      "id": 1,
      "organization_id": 1,
      "title": "Cardiology",
      "slug": "cardiology",
      "created_at": "2025-01-10T08:00:00Z",
      "updated_at": "2025-01-10T08:00:00Z"
    },
    {
      "id": 2,
      "organization_id": 1,
      "title": "Dermatology",
      "slug": "dermatology",
      "created_at": "2025-01-12T10:30:00Z",
      "updated_at": "2025-01-12T10:30:00Z"
    }
  ],
  "meta": {
    "page": 1,
    "page_size": 25,
    "total": 2,
    "total_pages": 1
  }
}

Errors

  • 401 unauthorized - Missing or invalid authentication token
  • 403 forbidden - User not a member of any organization

POST /v1/specialties

Create a new specialty. Admin-only.

Request

Headers:

Content-Type: application/json

Body:

json
{
  "title": "Cardiology",
  "slug": "cardiology"
}

Fields:

  • title (string, required) - Display name for the specialty
  • slug (string, required) - URL-safe identifier (must be unique within organization)

Response: 201 Created

json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "title": "Cardiology",
    "slug": "cardiology",
    "created_at": "2025-01-10T08:00:00Z",
    "updated_at": "2025-01-10T08:00:00Z"
  }
}

Errors

  • 400 validation_error - Missing or invalid fields
    json
    {
      "error": {
        "code": "validation_error",
        "message": "Validation failed",
        "details": {
          "fields": {
            "title": "is required",
            "slug": "must be lowercase alphanumeric with hyphens only"
          }
        }
      }
    }
  • 403 forbidden - Non-admin attempting to create specialty
  • 409 conflict - Slug already exists in this organization
    json
    {
      "error": {
        "code": "conflict",
        "message": "Specialty with slug 'cardiology' already exists in this organization"
      }
    }

GET /v1/specialties/{id}

Get a single specialty with related specialists.

Request

Path Parameters:

  • id (integer, required) - Specialty ID

Query Parameters:

  • include (string, optional) - Comma-separated list of relations to include (e.g., specialists)

Response: 200 OK

json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "title": "Cardiology",
    "slug": "cardiology",
    "created_at": "2025-01-10T08:00:00Z",
    "updated_at": "2025-01-10T08:00:00Z",
    "specialists": [
      {
        "id": 5,
        "name": "Dr. Jane Smith",
        "title": "Cardiologist",
        "avatar_url": "https://s3.../avatar.jpg"
      }
    ]
  }
}

Response: 200 OK (without include parameter)

json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "title": "Cardiology",
    "slug": "cardiology",
    "created_at": "2025-01-10T08:00:00Z",
    "updated_at": "2025-01-10T08:00:00Z"
  }
}

Errors

  • 404 not_found - Specialty doesn't exist or not accessible in current organization
    json
    {
      "error": {
        "code": "not_found",
        "message": "Specialty not found"
      }
    }

PUT /v1/specialties/{id}

Update a specialty. Admin-only.

Request

Path Parameters:

  • id (integer, required) - Specialty ID

Body:

json
{
  "title": "Cardiovascular Medicine",
  "slug": "cardiovascular-medicine"
}

Fields:

  • title (string, optional) - Updated display name
  • slug (string, optional) - Updated slug (must be unique within organization)

Response: 200 OK

json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "title": "Cardiovascular Medicine",
    "slug": "cardiovascular-medicine",
    "created_at": "2025-01-10T08:00:00Z",
    "updated_at": "2025-01-15T14:22:00Z"
  }
}

Errors

  • 400 validation_error - Invalid field values
  • 403 forbidden - Non-admin attempting to update specialty
  • 404 not_found - Specialty doesn't exist or not accessible
  • 409 conflict - New slug already exists in this organization

DELETE /v1/specialties/{id}

Delete a specialty. Admin-only.

Request

Path Parameters:

  • id (integer, required) - Specialty ID

Response: 204 No Content

No response body.

Errors

  • 403 forbidden - Non-admin attempting to delete specialty
  • 404 not_found - Specialty doesn't exist or not accessible
  • 409 conflict - Cannot delete specialty with active references
    json
    {
      "error": {
        "code": "conflict",
        "message": "Cannot delete specialty: 5 specialists are linked to this specialty"
      }
    }

Notes on Deletion

Foreign key constraints prevent deletion if:

  • Specialists are linked via specialist_specialties junction table
  • Appointment templates reference this specialty via specialty_id

The API returns a 409 conflict error with details about blocking references. Admins must first:

  1. Remove specialist associations, or
  2. Reassign appointment templates to a different specialty

Specialist-Specialty Association

Specialists link to specialties via the specialist_specialties junction table. Use the Specialists API to manage these associations:

  • POST /v1/specialists - Include specialty_ids array in request body
  • PUT /v1/specialists/{id} - Update specialty_ids array

Example:

json
{
  "name": "Dr. Jane Smith",
  "title": "Cardiologist",
  "specialty_ids": [1, 3]
}

See /docs/features/specialists/api.md for complete documentation.