Kulcare Docs
DeveloperFacilities

Facility Services API

Facility Services API for managing the service catalog and per-doctor pricing at a facility.

Base URL: /clinical/facilities/:facility_id/facility_services

Per-Doctor Pricing Model (PR #1470, re-homed on catalog.prices in #1838): Each facility has a service catalog (facility_services) with a default_price. Each doctor (practitioner role) can set their own price. Prices are stored in catalog.prices (the single price store since ADR B.3); the per-doctor row carries a practitioner_id dimension holding the doctor's PractitionerRole id, and the facility default is the practitioner_id NULL row. The pricings sub-resource and the embedded pricings array are the read/write surface over that store. Billing resolves the doctor's configured price first, falling back to default_price.

Related docs:

  • Service Pricing API: pricings endpoints (below)
  • Billing integration: patient-services-api.md, docs/brainstorms/2026-06-08-billing-packages-problem-and-solution.md
  • Procedure catalog: facility-procedures-api.md

Authentication

All requests require authentication via Bearer token:

Authorization: Bearer <token>

Data Models

FacilityService (Catalog Entry)

FieldTypeDescription
iduuidUnique identifier
namestringService name (unique per facility)
default_pricedecimalDefault/suggested price (10,2). Advisory only — billing uses per-doctor pricing.
default_durationintegerDefault duration in minutes (optional)
is_activebooleanWhether the service is active
service_typeenumType: consultation, telemedicine, opd, ipd, nursing, follow_up, other
created_atdatetimeCreation timestamp
updated_atdatetimeLast update timestamp
formatted_default_pricestringDefault price formatted as string
pricingsarrayPer-doctor pricing records. Always embedded (every view) since #1838 — include_pricings no longer gates it (it only selects the richer detailed view).

FacilityServicePricing (Per-Doctor Pricing)

FieldTypeDescription
iduuidUnique identifier
facility_service_iduuidReference to catalog service
practitioner_role_iduuidReference to doctor's practitioner role (the catalog.prices.practitioner_id dimension)
pricedecimalDoctor's price for this service
is_activebooleanWhether this pricing is active (listings return active rows only)
practitioner_roleobjectLight embedded role info: { id, display_name }

duration / custom_name were dropped with the old facility_service_pricings table (B.3) and are no longer returned.

Service Type Auto-Categorization

When creating a service, if service_type is not provided, it will be auto-categorized based on the service name:

  • Names matching *teleconsultation* → telemedicine
  • Names matching *consultation* → consultation
  • Names matching *follow*up* → follow_up
  • All other names → other

Endpoints

1. List Facility Services

GET /clinical/facilities/:facility_id/facility_services

Retrieve all services for a facility with filtering and sorting.

Query Parameters

ParameterTypeDefaultDescription
search_textstring-Search by service name (case-insensitive)
service_typestring-Filter by type: consultation, telemedicine, opd, ipd, nursing, follow_up, other
include_inactivebooleanfalseInclude inactive services
include_pricingsbooleanfalseInclude per-doctor pricing records for each service
sort_by_typestringcreated_atSort field: name, default_price, created_at
sort_by_orderstringdescSort order: asc, desc
pageinteger1Page number
per_pageinteger25Items per page

Request Example

curl -X GET "https://api.kulcare.com/clinical/facilities/{facility_id}/facility_services?search_text=consult&sort_by_type=name&sort_by_order=asc&include_pricings=true" \
  -H "Authorization: Bearer TOKEN"

Response Example

{
  "data": [
    {
      "id": "018f1234-5678-abcd-ef01-23456789abcd",
      "name": "General Consultation",
      "default_price": 500.00,
      "formatted_default_price": "500.00",
      "default_duration": 30,
      "service_type": "consultation",
      "is_active": true,
      "pricings": [
        {
          "id": "018f1234-5678-abcd-ef01-23456789abce",
          "facility_service_id": "018f1234-5678-abcd-ef01-23456789abcd",
          "practitioner_role_id": "018f1234-5678-abcd-ef01-practitioner1",
          "price": 600.00,
          "duration": 30,
          "custom_name": null,
          "is_active": true,
          "practitioner_role": {
            "id": "018f1234-5678-abcd-ef01-practitioner1",
            "display_name": "Dr. Raj Sharma",
            "practitioner": {
              "id": "018f1234-5678-abcd-ef01-doc1",
              "full_name": "Dr. Raj Sharma"
            }
          }
        }
      ]
    }
  ],
  "meta": {
    "request_id": "req_abc123",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1
    }
  }
}

2. Get Facility Service

GET /clinical/facilities/:facility_id/facility_services/:id

Retrieve a single facility service by ID.

Request Example

curl -X GET "https://api.kulcare.com/clinical/facilities/{facility_id}/facility_services/{id}" \
  -H "Authorization: Bearer TOKEN"

Response Example

{
  "data": {
    "id": "018f1234-5678-abcd-ef01-23456789abcd",
    "name": "General Consultation",
    "default_price": 500.00,
    "formatted_default_price": "500.00",
    "default_duration": 30,
    "is_active": true,
    "service_type": "consultation",
    "created_at": "2025-02-10T10:00:00Z",
    "updated_at": "2025-02-10T10:00:00Z",
    "facility": {
      "id": "018f9876-5432-dcba-fedc-0987654321ab",
      "name": "City Health Clinic"
    }
  },
  "meta": {
    "request_id": "req_abc123"
  }
}

3. Create Facility Service

POST /clinical/facilities/:facility_id/facility_services

Create a new facility service (catalog entry). Optionally creates a per-doctor pricing record if practitioner_role_id is provided (for doctor custom entries).

Request Body

{
  "facility_service": {
    "name": "General Consultation",
    "default_price": 500.00,
    "default_duration": 30,
    "is_active": true,
    "service_type": "consultation"
  }
}

Fields

FieldTypeRequiredDescription
namestringYesService name (unique per facility)
default_pricedecimalNoDefault/suggested price (>= 0). Optional — doctors set their own price.
default_durationintegerNoDefault duration in minutes (> 0)
is_activebooleanNoActive status (default: true)
service_typestringNoType: consultation, telemedicine, opd, ipd, nursing, follow_up, other
practitioner_role_iduuidNoIf provided, auto-creates a pricing record for this doctor (used for custom entries)

Request Example

curl -X POST "https://api.kulcare.com/clinical/facilities/{facility_id}/facility_services" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "facility_service": {
      "name": "General Consultation",
      "default_price": 500.00,
      "default_duration": 30,
      "service_type": "consultation"
    }
  }'

Response Example (201 Created)

{
  "data": {
    "id": "018f1234-5678-abcd-ef01-23456789abcd",
    "name": "General Consultation",
    "default_price": 500.00,
    "formatted_default_price": "500.00",
    "default_duration": 30,
    "is_active": true,
    "service_type": "consultation",
    "created_at": "2025-02-10T10:00:00Z",
    "updated_at": "2025-02-10T10:00:00Z",
    "facility": {
      "id": "018f9876-5432-dcba-fedc-0987654321ab",
      "name": "City Health Clinic"
    }
  },
  "meta": {
    "request_id": "req_abc123"
  }
}

Error Response (422 Unprocessable Entity)

{
  "data": null,
  "error": {
    "code": "validation_error",
    "message": "Validation failed",
    "details": [
      {
        "field": "name",
        "message": "General Consultation already exists"
      }
    ]
  },
  "meta": {
    "request_id": "req_abc123"
  }
}

4. Update Facility Service

PATCH /clinical/facilities/:facility_id/facility_services/:id

Update an existing facility service (catalog entry).

Request Body

All fields are optional.

{
  "facility_service": {
    "name": "Specialist Consultation",
    "default_price": 750.00,
    "is_active": false
  }
}

Request Example

curl -X PATCH "https://api.kulcare.com/clinical/facilities/{facility_id}/facility_services/{id}" \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "facility_service": {
      "default_price": 750.00
    }
  }'

Response Example

{
  "data": {
    "id": "018f1234-5678-abcd-ef01-23456789abcd",
    "name": "General Consultation",
    "default_price": 750.00,
    "formatted_default_price": "750.00",
    "default_duration": 30,
    "is_active": true,
    "service_type": "consultation",
    "created_at": "2025-02-10T10:00:00Z",
    "updated_at": "2025-02-10T11:30:00Z",
    "facility": {
      "id": "018f9876-5432-dcba-fedc-0987654321ab",
      "name": "City Health Clinic"
    }
  },
  "meta": {
    "request_id": "req_abc123"
  }
}

5. Delete Facility Service

DELETE /clinical/facilities/:facility_id/facility_services/:id

Delete a facility service (catalog entry).

Blocking rule: Cannot delete a service that has active per-doctor pricing records. All pricings must be removed first. This prevents accidentally deleting a catalog entry that doctors are actively using.

Request Example

curl -X DELETE "https://api.kulcare.com/clinical/facilities/{facility_id}/facility_services/{id}" \
  -H "Authorization: Bearer TOKEN"

Response Example (204 No Content)

Empty response body with status code 204 No Content.

Error Response (422 — Has Active Pricings)

{
  "data": null,
  "error": {
    "code": "validation_error",
    "message": "Cannot delete service with active pricings. Remove all doctor pricings first.",
    "details": [
      {
        "field": "base",
        "message": "Cannot delete service with active pricings. Remove all doctor pricings first."
      }
    ]
  },
  "meta": {
    "request_id": "req_abc123"
  }
}

Per-Doctor Pricing Endpoints

6. List Service Pricings

GET /clinical/facilities/:facility_id/facility_services/:facility_service_id/pricings

Retrieve all per-doctor pricing records for a specific service.

Returns the active per-doctor pricing records for the service (the facility-default row is not included — it is managed on the parent service via default_price).

Response Example

{
  "data": [
    {
      "id": "018f1234-5678-abcd-ef01-23456789abce",
      "facility_service_id": "018f1234-5678-abcd-ef01-23456789abcd",
      "practitioner_role_id": "018f1234-5678-abcd-ef01-practitioner1",
      "price": 600.00,
      "is_active": true,
      "practitioner_role": {
        "id": "018f1234-5678-abcd-ef01-practitioner1",
        "display_name": "Dr. Raj Sharma"
      }
    }
  ]
}

7. Create Service Pricing

POST /clinical/facilities/:facility_id/facility_services/:facility_service_id/pricings

Create a per-doctor pricing record for a service.

Request Body

{
  "facility_service_pricing": {
    "practitioner_role_id": "018f1234-5678-abcd-ef01-practitioner1",
    "price": 600.00
  }
}

Fields

FieldTypeRequiredDescription
practitioner_role_iduuidYesDoctor's practitioner role at this facility (must belong to the facility, else 422)
pricedecimalYesDoctor's price (must be positive)

create is an upsert on (service, role): a second POST for the same doctor updates the price rather than creating a duplicate.

8. Update Service Pricing

PATCH /clinical/facilities/:facility_id/facility_services/:facility_service_id/pricings/:id

Update a per-doctor pricing record's price.

9. Delete Service Pricing (Hard Delete)

DELETE /clinical/facilities/:facility_id/facility_services/:facility_service_id/pricings/:id

Permanently remove a doctor's pricing for this service. This is a hard delete — no soft delete / deleted_at. The doctor can always re-create the pricing later.


Price Resolution for Billing

When billing a service for a patient, price is resolved as follows:

  1. Frontend sends facility_service_id + a per-line practitioner_id
  2. Backend maps it to PractitionerRole.find_by(practitioner_id:, facility_id:)
  3. Backend resolves the price via Catalog::PriceResolver (per-doctor row in catalog.prices for that role)
  4. If a doctor price is found → use it (the doctor's configured price)
  5. If none → fall back to facility_service.default_price
  6. If neither → reject with error

Authoritative server-side enforcement of this resolution (overriding the client-sent unit_price) lands with the resolver wiring in #1848; the billing panel already resolves and previews per #1838.

See docs/brainstorms/2026-06-08-billing-packages-problem-and-solution.md for full billing flow.


Error Codes

CodeDescription
not_foundResource not found
validation_errorRequest validation failed
unauthorizedAuthentication required or insufficient permissions

TypeScript Types

// Service type enum (7 values, maps to ABDM billing codes)
type ServiceType = 'consultation' | 'telemedicine' | 'opd' | 'ipd' | 'nursing' | 'follow_up' | 'other';

// Facility service (catalog entry)
interface FacilityService {
  id: string;
  name: string;
  default_price?: number;
  formatted_default_price?: string;
  default_duration?: number;
  is_active: boolean;
  service_type: ServiceType;
  created_at: string;
  updated_at: string;
  pricings?: FacilityServicePricing[];
  facility?: {
    id: string;
    name: string;
  };
}

// Per-doctor service pricing
interface FacilityServicePricing {
  id: string;
  facility_service_id: string;
  practitioner_role_id: string;
  price: number;
  duration?: number;
  custom_name?: string | null;
  is_active: boolean;
  created_at: string;
  updated_at: string;
  practitioner_role?: {
    id: string;
    display_name?: string;
    full_name?: string;
    practitioner?: {
      id: string;
      full_name?: string;
    };
  };
}

// List request params
interface ListFacilityServicesParams {
  search_text?: string;
  service_type?: ServiceType;
  include_inactive?: boolean;
  include_pricings?: boolean;
  sort_by_type?: 'name' | 'default_price' | 'created_at';
  sort_by_order?: 'asc' | 'desc';
  page?: number;
  per_page?: number;
}

// Create request body
interface CreateFacilityServiceRequest {
  facility_service: {
    name: string;
    default_price?: number;
    default_duration?: number;
    is_active?: boolean;
    service_type?: ServiceType;
    practitioner_role_id?: string;
  };
}

// Update request body
interface UpdateFacilityServiceRequest {
  facility_service: {
    name?: string;
    default_price?: number;
    default_duration?: number;
    is_active?: boolean;
    service_type?: ServiceType;
  };
}

// API response wrapper
interface ApiResponse<T> {
  data: T | T[] | null;
  error?: {
    code: string;
    message: string;
    details: Array<{ field: string; message: string }>;
  };
  meta: {
    request_id: string;
    pagination?: {
      page: number;
      per_page: number;
      total: number;
    };
  };
}

On this page