Kulcare Docs
DeveloperPatients

Patient Services API Reference

Billing domain API for managing patient services (billable services rendered to patients)

Base Information

PropertyValue
Base URL/billing
AuthenticationBearer JWT Token
Content-Typeapplication/json

Endpoints Overview

MethodEndpointDescription
GET/patient_facilities/:patient_facility_id/servicesList services for a patient_facility
POST/patient_facilities/:patient_facility_id/servicesCreate a new service
GET/patient_services/:idGet service details
PATCH/patient_services/:idUpdate a service
DELETE/patient_services/:idDelete a service

1. List Services

List all services for a specific patient_facility.

Request

GET /billing/patient_facilities/:patient_facility_id/services

Query Parameters

ParameterTypeRequiredDescription
facility_idUUIDNoFilter by facility
practitioner_idUUIDNoFilter by practitioner
category_idUUIDNoFilter by category
encounter_idUUIDNoFilter by encounter
service_typestringNoFilter by type: consultation, procedure, laboratory, pharmacy, diagnostic, therapy, other
statusstringNoFilter by status: pending, completed, cancelled
is_paidstringNoFilter by payment: true, false
is_billedstringNoFilter by billing: true, false
start_datestringNoFilter from date (YYYY-MM-DD)
end_datestringNoFilter to date (YYYY-MM-DD)
searchstringNoSearch by service name
patient_namestringNoSearch by patient name
pageintegerNoPage number (default: 1)
per_pageintegerNoItems per page (default: 25, max: 100)
sort_bystringNoSort field (default: service_datetime)
sort_orderstringNoSort direction: asc, desc (default: desc)

Example Request

curl -X GET "https://api.example.com/billing/patient_facilities/550e8400-e29b-41d4-a716-446655440000/services?page=1&per_page=10&status=pending" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json"

Response

{
  "data": [
    {
      "id": "660e8400-e29b-41d4-a716-446655440001",
      "service_number": "PSV-2024-000001",
      "service_name": "General Consultation",
      "service_type": "consultation",
      "status": "pending",
      "service_datetime": "2024-01-15T10:00:00Z",
      "quantity": 1.0,
      "unit_price": 1000.0,
      "total_amount": 1000.0,
      "is_billed": false,
      "is_paid": false,
      "billing_status": "unbilled",
      "payment_status": "unpaid",
      "patient": {
        "id": "770e8400-e29b-41d4-a716-446655440001",
        "name": "John Doe",
        "phone": "+919876543210"
      },
      "practitioner": {
        "id": "880e8400-e29b-41d4-a716-446655440001",
        "name": "Dr. Jane Smith"
      }
    }
  ],
  "meta": {
    "request_id": "uuid-here",
    "pagination": {
      "total_entries": 25,
      "total_pages": 3,
      "current_page": 1,
      "per_page": 10
    }
  }
}

2. Create Service

Create a new patient service.

Request

POST /billing/patient_facilities/:patient_facility_id/services

Request Body

FieldTypeRequiredDescription
service_namestringYesName of the service
service_datetimedatetimeYesDate and time of service (ISO 8601)
quantitydecimalYesQuantity (default: 1)
unit_pricedecimalConditionalPrice per unit. Required for a custom (non-catalog) line, for a manual override (override_reason set), and for a catalog line whose item has no catalog price yet. On any other catalog line (facility_service_id/facility_procedure_id) it is ignored in favour of the resolved catalog price.
service_typestringNoType: consultation, procedure, laboratory, pharmacy, diagnostic, therapy, other
descriptionstringNoService description
facility_idUUIDNoFacility ID (defaults to patient_facility's facility)
practitioner_idUUIDNoPractitioner who provided the service
encounter_idUUIDNoAssociated encounter/appointment
episode_of_care_idUUIDNoAssociated episode of care
facility_service_idUUIDNoReference to facility's service catalog (mutually exclusive with facility_procedure_id)
facility_procedure_idUUIDNoReference to facility's procedure catalog (mutually exclusive with facility_service_id) (#1839)
category_idUUIDNoService category
discount_percentdecimalNoDiscount percentage (0-100)
discount_amountdecimalNoDiscount amount (use either percent OR amount, not both)
override_reasonstringNoAudit reason for a manual price override (#1838). With a non-blank reason, a catalog line bills the client unit_price instead of the resolved catalog price (also when the item has no catalog price yet); unit_price is then required, must be ≥ 0 and below 10,000,000,000, and no automatic category discount is added on top. A line a care package fully covers cannot be overridden (the create is rejected and no benefit is consumed); a partially covered line keeps the override price for its units. Re-entering the resolved catalog price is not an override: the line bills as catalog and override_reason is stored as null. A value that overflows its numeric column (e.g. the line total) returns A numeric value is out of range. Whitespace-only reasons are ignored (stored as null). These rules apply to a catalog line. A custom (non-catalog) line has no resolved price to override, so it always bills the unit_price it was sent and a reason there is recorded for audit only.
reference_codestringNoFacility reference code, used downstream at invoicing.
abdm_codestringNoABDM code, used downstream at claim time.
metadataobjectNoAdditional metadata

Per-doctor pricing (#1838). practitioner_id is per-line — a single visit can bill each service under the doctor who performed it. The billing panel resolves the unit price from the practitioner's configured price in catalog.prices (see the facility-services / facility-procedures API pricings), falling back to the catalog default. Staff can manually override the resolved price; when they do, send unit_price and override_reason. Without a reason, a client unit_price on a catalog line is ignored and the catalog price is billed.

The line's metadata.price_source records where the billed price came from: catalog (resolved per-doctor/facility default), client (no catalog price existed, client price used), or override (manual override with a reason). For an override, metadata.catalog_unit_price holds the catalog price it replaced (absent if no catalog price existed). These keys (and category_discount_applied) are server-owned: client-sent values for them are dropped.

Procedures + package benefits (#1839). A line references either a facility_service_id or a facility_procedure_id (never both). For a procedure line the price is resolved from catalog.prices for Clinical::FacilityProcedure (server-authoritative, same as services). If the patient has an active care-package subscription whose package includes a component for that service/procedure, the line is auto-covered: care_package_service_id + benefits_used are set server-side, the net is discounted to zero, and the subscription's benefit ledger (keyed by the bare service/procedure UUID) is decremented under a row lock. Do not send care_package_service_id / benefits_used to fake coverage — the server is the sole writer of the ledger.

GST is server-owned (#1840). tax_percent / tax_amount are not client input — they're resolved server-side from the catalog item's GST taxability (facility_services/facility_procedures is_taxable + gst_rate) and a facility gate: GST applies only when the facility's primary-location country is IN and the facility is GST-registered (a GSTIN on its billing address). Indian healthcare is exempt by default (most clinical services); only items explicitly marked taxable (e.g. pharmacy, cosmetic procedures) carry a rate. The resolved rate is snapped onto the line (tax_total on the net-payable amount), so a package-covered line (net 0) is 0 GST. Any client-sent tax_percent/tax_amount is ignored. See docs/guides/developer/billing/per-service-payment-tracking-and-gst.md.

Per-service payment tracking (#1840). Each line carries paid_amount (payment_status = paid when paid_amount >= total_amount), both server-owned. A recorded payment is distributed DOWN to the individual service lines FIFO (by invoice-line creation order) via a dedicated billing.service_payment_allocations ledger, so staff can see which lines are paid. paid_amount/is_paid are never client-writable, and a line with recorded payment cannot be deleted.

Legacy Format Support

The API also accepts legacy flat parameters for backward compatibility:

Legacy FieldMaps To
service_dateservice_datetime
priceunit_price
discount_percentagediscount_percent
clinic_service_idfacility_service_id
clinic_idfacility_id
doctor_idpractitioner_id
appointment_idencounter_id
curl -X POST "https://api.example.com/billing/patient_facilities/550e8400-e29b-41d4-a716-446655440000/services" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "patient_service": {
      "service_name": "General Consultation",
      "service_type": "consultation",
      "service_datetime": "2024-01-15T10:00:00Z",
      "quantity": 1,
      "unit_price": 1000.00,
      "practitioner_id": "880e8400-e29b-41d4-a716-446655440001",
      "encounter_id": "990e8400-e29b-41d4-a716-446655440001",
      "facility_service_id": "aa0e8400-e29b-41d4-a716-446655440001",
      "description": "Follow-up consultation"
    }
  }'

Example Request (Legacy Flat Format)

curl -X POST "https://api.example.com/billing/patient_facilities/550e8400-e29b-41d4-a716-446655440000/services" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "service_name": "General Consultation",
    "service_type": "consultation",
    "service_date": "2024-01-15T10:00:00Z",
    "quantity": 1,
    "price": 1000.00,
    "doctor_id": "880e8400-e29b-41d4-a716-446655440001",
    "appointment_id": "990e8400-e29b-41d4-a716-446655440001",
    "clinic_service_id": "aa0e8400-e29b-41d4-a716-446655440001"
  }'

Response (201 Created)

{
  "data": {
    "id": "660e8400-e29b-41d4-a716-446655440001",
    "service_number": "PSV-2024-000001",
    "service_name": "General Consultation",
    "service_type": "consultation",
    "status": "pending",
    "service_datetime": "2024-01-15T10:00:00Z",
    "quantity": 1.0,
    "unit_price": 1000.0,
    "total_amount": 1000.0,
    "is_billed": false,
    "is_paid": false,
    "billing_status": "unbilled",
    "payment_status": "unpaid",
    "patient": {
      "id": "770e8400-e29b-41d4-a716-446655440001",
      "name": "John Doe",
      "phone": "+919876543210"
    },
    "practitioner": {
      "id": "880e8400-e29b-41d4-a716-446655440001",
      "name": "Dr. Jane Smith"
    }
  },
  "meta": {
    "request_id": "uuid-here"
  }
}

3. Get Service Details

Get detailed information about a specific service.

Request

GET /billing/patient_services/:id

Query Parameters

ParameterTypeRequiredDescription
viewstringNoResponse view: normal, detailed (default: detailed)

Example Request

curl -X GET "https://api.example.com/billing/patient_services/660e8400-e29b-41d4-a716-446655440001" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json"

Response

{
  "data": {
    "id": "660e8400-e29b-41d4-a716-446655440001",
    "service_number": "PSV-2024-000001",
    "service_name": "General Consultation",
    "service_type": "consultation",
    "status": "pending",
    "service_datetime": "2024-01-15T10:00:00Z",
    "quantity": 1.0,
    "unit_price": 1000.0,
    "total_amount": 1000.0,
    "is_billed": false,
    "is_paid": false,
    "billing_status": "unbilled",
    "payment_status": "unpaid",
    "description": "Follow-up consultation",
    "discount_percent": 0.0,
    "discount_amount": 0.0,
    "patient": {
      "id": "770e8400-e29b-41d4-a716-446655440001",
      "name": "John Doe",
      "phone": "+919876543210"
    },
    "practitioner": {
      "id": "880e8400-e29b-41d4-a716-446655440001",
      "name": "Dr. Jane Smith"
    },
    "facility": {
      "id": "bb0e8400-e29b-41d4-a716-446655440001",
      "name": "Main Clinic"
    },
    "encounter": {
      "id": "990e8400-e29b-41d4-a716-446655440001",
      "status": "in_progress"
    },
    "facility_service": {
      "id": "aa0e8400-e29b-41d4-a716-446655440001",
      "name": "Consultation Service"
    },
    "created_at": "2024-01-15T09:00:00Z",
    "updated_at": "2024-01-15T09:00:00Z"
  },
  "meta": {
    "request_id": "uuid-here"
  }
}

4. Update Service

Update an existing service. Note: Cannot update a service that has been billed.

Request

PATCH /billing/patient_services/:id

Request Body

Same fields as Create Service. All fields are optional.

A catalog line's price is guarded on PATCH exactly as on create (#2569). Until this landed, update sliced unit_price straight through with no reason, no bounds, no catalog re-resolution and no package guard — so every rule below was bypassable by editing a line instead of adding one. Both paths now run the same rules (Billing::ManualPriceOverride).

On a catalog line:

Submitted unit_priceBehaviour
equals the line's current price, on an unchanged linenot a price change: the line keeps its price, its price_source and its reason, even if the catalog price has moved since it was billed — editing a quantity or a discount never needs a reason (#2784). "Unchanged" means the request does not re-link the line to another catalog item or another doctor; a re-link at the old price is judged against the new catalog price like any other value
equals the resolved catalog pricebills as catalog, needs no reason, and clears any stored override_reason
any other valueneeds a non-blank override_reason, is rounded to 2dp before the range check, and is rejected on a fully package-covered line

A custom (non-catalog) line has no resolved price to compare against, so it keeps the unit_price it was sent, as on create.

override_reason cannot be cleared on its own. A PATCH carrying a blank reason and no unit_price is rejected while the line still bills an overridden price — otherwise the audit reason vanished while the money and metadata.price_source: override stayed. Restore the catalog price to clear it. A reason sent to a line that is not an override is dropped, since it would audit nothing.

override_reason, facility_service_id and facility_procedure_id are returned in the list (:normal) view as well as :detailed, so a client editing a line straight from a list it rendered knows it is a catalog line and can show and resend what is already stored (#2784).

Example Request

curl -X PATCH "https://api.example.com/billing/patient_services/660e8400-e29b-41d4-a716-446655440001" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "patient_service": {
      "quantity": 2,
      "unit_price": 900.00,
      "discount_percent": 10
    }
  }'

Response

{
  "data": {
    "id": "660e8400-e29b-41d4-a716-446655440001",
    "service_number": "PSV-2024-000001",
    "service_name": "General Consultation",
    "service_type": "consultation",
    "status": "pending",
    "service_datetime": "2024-01-15T10:00:00Z",
    "quantity": 2.0,
    "unit_price": 900.0,
    "total_amount": 1620.0,
    "is_billed": false,
    "is_paid": false,
    "billing_status": "unbilled",
    "payment_status": "unpaid",
    "description": "Follow-up consultation",
    "discount_percent": 10.0,
    "discount_amount": 0.0,
    "patient": {
      "id": "770e8400-e29b-41d4-a716-446655440001",
      "name": "John Doe",
      "phone": "+919876543210"
    },
    "practitioner": {
      "id": "880e8400-e29b-41d4-a716-446655440001",
      "name": "Dr. Jane Smith"
    },
    "facility": {
      "id": "bb0e8400-e29b-41d4-a716-446655440001",
      "name": "Main Clinic"
    },
    "encounter": {
      "id": "990e8400-e29b-41d4-a716-446655440001",
      "status": "in_progress"
    },
    "facility_service": {
      "id": "aa0e8400-e29b-41d4-a716-446655440001",
      "name": "Consultation Service"
    },
    "created_at": "2024-01-15T09:00:00Z",
    "updated_at": "2024-01-15T10:30:00Z"
  },
  "meta": {
    "request_id": "uuid-here"
  }
}

5. Delete Service

Soft delete a service. Note: Cannot delete a service that has been billed.

Request

DELETE /billing/patient_services/:id

Query Parameters

ParameterTypeRequiredDescription
reasonstringYesReason for deletion

Example Request

curl -X DELETE "https://api.example.com/billing/patient_services/660e8400-e29b-41d4-a716-446655440001?reason=Patient%20cancelled" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json"

Response (204 No Content)

Empty response body with HTTP status 204.


Error Responses

Validation Error (422 Unprocessable Entity)

{
  "errors": [
    {
      "field": "service_name",
      "message": "can't be blank"
    },
    {
      "field": "unit_price",
      "message": "must be greater than or equal to 0"
    }
  ],
  "meta": {
    "request_id": "uuid-here"
  }
}

Not Found Error (404 Not Found)

{
  "errors": [
    {
      "field": "base",
      "message": "PatientService not found"
    }
  ],
  "meta": {
    "request_id": "uuid-here"
  }
}

Authorization Error (403 Forbidden)

{
  "errors": [
    {
      "field": "base",
      "message": "You are not authorized to perform this action"
    }
  ],
  "meta": {
    "request_id": "uuid-here"
  }
}

Business Logic Errors

ErrorHTTP StatusMessage
Service already billed422"Cannot update/delete a service that has been billed"
Invalid discount422"Provide either discount_percent or discount_amount, not both"
Discount exceeds total422"Discount amount cannot exceed service total"
Discount exceeds 100%422"Discount percentage cannot exceed 100%"
Reason required for deletion422"Reason is required for deletion"

Enums Reference

Service Type

ValueDescription
consultationDoctor consultation
procedureMedical procedure
laboratoryLab tests
pharmacyMedicines/pharmacy items
diagnosticDiagnostic tests
therapyTherapy sessions
otherOther services

Status

ValueDescription
pendingService pending
completedService completed
cancelledService cancelled

TypeScript Types

// Service Types
type ServiceType = 'consultation' | 'procedure' | 'laboratory' | 'pharmacy' | 'diagnostic' | 'therapy' | 'other';
type ServiceStatus = 'pending' | 'completed' | 'cancelled';

// Service Response
interface PatientService {
  id: string;
  service_number: string;
  service_name: string;
  service_type: ServiceType;
  status: ServiceStatus;
  service_datetime: string;
  quantity: number;
  unit_price: number;
  total_amount: number;
  is_billed: boolean;
  is_paid: boolean;
  billing_status: 'billed' | 'unbilled';
  payment_status: 'paid' | 'unpaid';
  description?: string;
  discount_percent?: number;
  discount_amount?: number;
  patient: {
    id: string;
    name: string;
    phone: string;
  } | null;
  practitioner: {
    id: string;
    name: string;
  } | null;
  facility?: {
    id: string;
    name: string;
  };
  encounter?: {
    id: string;
    status: string;
  };
  facility_service?: {
    id: string;
    name: string;
  };
  created_at?: string;
  updated_at?: string;
}

// Create Request
interface CreatePatientServiceRequest {
  service_name: string;
  service_datetime: string;
  quantity: number;
  unit_price: number;
  service_type?: ServiceType;
  description?: string;
  facility_id?: string;
  practitioner_id?: string;
  encounter_id?: string;
  episode_of_care_id?: string;
  facility_service_id?: string;
  facility_procedure_id?: string;
  category_id?: string;
  discount_percent?: number;
  discount_amount?: number;
  tax_percent?: number;
  tax_amount?: number;
  care_package_service_id?: string;
  benefits_used?: number;
  metadata?: Record<string, unknown>;
}

// Update Request
interface UpdatePatientServiceRequest {
  service_name?: string;
  service_datetime?: string;
  quantity?: number;
  unit_price?: number;
  service_type?: ServiceType;
  description?: string;
  practitioner_id?: string;
  encounter_id?: string;
  facility_service_id?: string;
  facility_procedure_id?: string;
  category_id?: string;
  discount_percent?: number;
  discount_amount?: number;
  tax_percent?: number;
  tax_amount?: number;
  status?: ServiceStatus;
}

// List Query Params
interface ListServicesParams {
  facility_id?: string;
  practitioner_id?: string;
  category_id?: string;
  encounter_id?: string;
  service_type?: ServiceType;
  status?: ServiceStatus;
  is_paid?: 'true' | 'false';
  is_billed?: 'true' | 'false';
  start_date?: string;
  end_date?: string;
  search?: string;
  patient_name?: string;
  page?: number;
  per_page?: number;
  sort_by?: string;
  sort_order?: 'asc' | 'desc';
}

// API Response
interface ApiResponse<T> {
  data: T;
  meta: {
    request_id: string;
    pagination?: {
      total_entries: number;
      total_pages: number;
      current_page: number;
      per_page: number;
    };
  };
}

// Error Response
interface ApiErrorResponse {
  errors: Array<{
    field: string;
    message: string;
  }>;
  meta: {
    request_id: string;
  };
}

React Query Hooks Example

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@kulcare/api-client';

// List services
export function usePatientServices(patientFacilityId: string, params?: ListServicesParams) {
  return useQuery({
    queryKey: ['patient-services', patientFacilityId, params],
    queryFn: () =>
      apiClient.get<ApiResponse<PatientService[]>>(
        `/billing/patient_facilities/${patientFacilityId}/services`,
        { params }
      ),
  });
}

// Get single service
export function usePatientService(id: string) {
  return useQuery({
    queryKey: ['patient-service', id],
    queryFn: () =>
      apiClient.get<ApiResponse<PatientService>>(`/billing/patient_services/${id}`),
    enabled: !!id,
  });
}

// Create service
export function useCreatePatientService() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({
      patientFacilityId,
      data,
    }: {
      patientFacilityId: string;
      data: CreatePatientServiceRequest;
    }) =>
      apiClient.post<ApiResponse<PatientService>>(
        `/billing/patient_facilities/${patientFacilityId}/services`,
        { patient_service: data }
      ),
    onSuccess: (_, { patientFacilityId }) => {
      queryClient.invalidateQueries({ queryKey: ['patient-services', patientFacilityId] });
    },
  });
}

// Update service
export function useUpdatePatientService() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({
      id,
      data,
    }: {
      id: string;
      data: UpdatePatientServiceRequest;
    }) =>
      apiClient.patch<ApiResponse<PatientService>>(
        `/billing/patient_services/${id}`,
        { patient_service: data }
      ),
    onSuccess: (_, { id }) => {
      queryClient.invalidateQueries({ queryKey: ['patient-service', id] });
      queryClient.invalidateQueries({ queryKey: ['patient-services'] });
    },
  });
}

// Delete service
export function useDeletePatientService() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({
      id,
      reason,
    }: {
      id: string;
      reason: string;
    }) =>
      apiClient.delete(`/billing/patient_services/${id}`, {
        params: { reason },
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['patient-services'] });
    },
  });
}

Notes for Frontend Integration

  1. Service Number: Auto-generated in format PSV-YYYY-NNNNNN (e.g., PSV-2024-000001)

  2. Total Amount Calculation:

    • gross_amount = quantity × unit_price
    • discount_total = discount_percent ? (gross_amount × discount_percent / 100) : discount_amount
    • net_amount = gross_amount - discount_total
    • tax_total = tax_percent ? (net_amount × tax_percent / 100) : tax_amount — tax_percent is server-resolved GST (#1840), not client input; 0 for exempt lines / non-IN / unregistered facilities
    • total_amount = net_amount + tax_total
  3. Discount Validation: Use either discount_percent OR discount_amount, never both

  4. Billed Services: Once a service is billed (is_billed: true), it cannot be updated or deleted

  5. Soft Delete: Deleted services are soft-deleted (status changes to cancelled, deleted_at is set)

  6. Legacy Support: The API accepts both nested (patient_service: {...}) and flat parameter formats

  7. Patient Facility Context: All create/list operations require a patient_facility_id in the URL

On this page