Patient Services API Reference
Billing domain API for managing patient services (billable services rendered to patients)
Base Information
| Property | Value |
|---|---|
| Base URL | /billing |
| Authentication | Bearer JWT Token |
| Content-Type | application/json |
Endpoints Overview
| Method | Endpoint | Description |
|---|---|---|
| GET | /patient_facilities/:patient_facility_id/services | List services for a patient_facility |
| POST | /patient_facilities/:patient_facility_id/services | Create a new service |
| GET | /patient_services/:id | Get service details |
| PATCH | /patient_services/:id | Update a service |
| DELETE | /patient_services/:id | Delete a service |
1. List Services
List all services for a specific patient_facility.
Request
GET /billing/patient_facilities/:patient_facility_id/servicesQuery Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
facility_id | UUID | No | Filter by facility |
practitioner_id | UUID | No | Filter by practitioner |
category_id | UUID | No | Filter by category |
encounter_id | UUID | No | Filter by encounter |
service_type | string | No | Filter by type: consultation, procedure, laboratory, pharmacy, diagnostic, therapy, other |
status | string | No | Filter by status: pending, completed, cancelled |
is_paid | string | No | Filter by payment: true, false |
is_billed | string | No | Filter by billing: true, false |
start_date | string | No | Filter from date (YYYY-MM-DD) |
end_date | string | No | Filter to date (YYYY-MM-DD) |
search | string | No | Search by service name |
patient_name | string | No | Search by patient name |
page | integer | No | Page number (default: 1) |
per_page | integer | No | Items per page (default: 25, max: 100) |
sort_by | string | No | Sort field (default: service_datetime) |
sort_order | string | No | Sort 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/servicesRequest Body
| Field | Type | Required | Description |
|---|---|---|---|
service_name | string | Yes | Name of the service |
service_datetime | datetime | Yes | Date and time of service (ISO 8601) |
quantity | decimal | Yes | Quantity (default: 1) |
unit_price | decimal | Conditional | Price 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_type | string | No | Type: consultation, procedure, laboratory, pharmacy, diagnostic, therapy, other |
description | string | No | Service description |
facility_id | UUID | No | Facility ID (defaults to patient_facility's facility) |
practitioner_id | UUID | No | Practitioner who provided the service |
encounter_id | UUID | No | Associated encounter/appointment |
episode_of_care_id | UUID | No | Associated episode of care |
facility_service_id | UUID | No | Reference to facility's service catalog (mutually exclusive with facility_procedure_id) |
facility_procedure_id | UUID | No | Reference to facility's procedure catalog (mutually exclusive with facility_service_id) (#1839) |
category_id | UUID | No | Service category |
discount_percent | decimal | No | Discount percentage (0-100) |
discount_amount | decimal | No | Discount amount (use either percent OR amount, not both) |
override_reason | string | No | Audit 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_code | string | No | Facility reference code, used downstream at invoicing. |
abdm_code | string | No | ABDM code, used downstream at claim time. |
metadata | object | No | Additional metadata |
Per-doctor pricing (#1838).
practitioner_idis 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 incatalog.prices(see the facility-services / facility-procedures APIpricings), falling back to the catalog default. Staff can manually override the resolved price; when they do, sendunit_priceandoverride_reason. Without a reason, a clientunit_priceon a catalog line is ignored and the catalog price is billed.The line's
metadata.price_sourcerecords where the billed price came from:catalog(resolved per-doctor/facility default),client(no catalog price existed, client price used), oroverride(manual override with a reason). For an override,metadata.catalog_unit_priceholds the catalog price it replaced (absent if no catalog price existed). These keys (andcategory_discount_applied) are server-owned: client-sent values for them are dropped.
Procedures + package benefits (#1839). A line references either a
facility_service_idor afacility_procedure_id(never both). For a procedure line the price is resolved fromcatalog.pricesforClinical::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_usedare 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 sendcare_package_service_id/benefits_usedto fake coverage — the server is the sole writer of the ledger.
GST is server-owned (#1840).
tax_percent/tax_amountare not client input — they're resolved server-side from the catalog item's GST taxability (facility_services/facility_proceduresis_taxable+gst_rate) and a facility gate: GST applies only when the facility's primary-location country isINand 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_totalon the net-payable amount), so a package-covered line (net 0) is 0 GST. Any client-senttax_percent/tax_amountis ignored. Seedocs/guides/developer/billing/per-service-payment-tracking-and-gst.md.
Per-service payment tracking (#1840). Each line carries
paid_amount(payment_status=paidwhenpaid_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 dedicatedbilling.service_payment_allocationsledger, so staff can see which lines are paid.paid_amount/is_paidare 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 Field | Maps To |
|---|---|
service_date | service_datetime |
price | unit_price |
discount_percentage | discount_percent |
clinic_service_id | facility_service_id |
clinic_id | facility_id |
doctor_id | practitioner_id |
appointment_id | encounter_id |
Example Request (Nested Format - Recommended)
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/:idQuery Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
view | string | No | Response 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/:idRequest 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,
updateslicedunit_pricestraight 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 line not a price change: the line keeps its price, its price_sourceand 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 valueequals the resolved catalog price bills as catalog, needs no reason, and clears any storedoverride_reasonany other value needs a non-blank override_reason, is rounded to 2dp before the range check, and is rejected on a fully package-covered lineA custom (non-catalog) line has no resolved price to compare against, so it keeps the
unit_priceit was sent, as on create.
override_reasoncannot be cleared on its own. A PATCH carrying a blank reason and nounit_priceis rejected while the line still bills an overridden price — otherwise the audit reason vanished while the money andmetadata.price_source: overridestayed. 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_idandfacility_procedure_idare 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/:idQuery Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
reason | string | Yes | Reason 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
| Error | HTTP Status | Message |
|---|---|---|
| Service already billed | 422 | "Cannot update/delete a service that has been billed" |
| Invalid discount | 422 | "Provide either discount_percent or discount_amount, not both" |
| Discount exceeds total | 422 | "Discount amount cannot exceed service total" |
| Discount exceeds 100% | 422 | "Discount percentage cannot exceed 100%" |
| Reason required for deletion | 422 | "Reason is required for deletion" |
Enums Reference
Service Type
| Value | Description |
|---|---|
consultation | Doctor consultation |
procedure | Medical procedure |
laboratory | Lab tests |
pharmacy | Medicines/pharmacy items |
diagnostic | Diagnostic tests |
therapy | Therapy sessions |
other | Other services |
Status
| Value | Description |
|---|---|
pending | Service pending |
completed | Service completed |
cancelled | Service 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
-
Service Number: Auto-generated in format
PSV-YYYY-NNNNNN(e.g.,PSV-2024-000001) -
Total Amount Calculation:
gross_amount = quantity × unit_pricediscount_total = discount_percent ? (gross_amount × discount_percent / 100) : discount_amountnet_amount = gross_amount - discount_totaltax_total = tax_percent ? (net_amount × tax_percent / 100) : tax_amount—tax_percentis server-resolved GST (#1840), not client input; 0 for exempt lines / non-IN / unregistered facilitiestotal_amount = net_amount + tax_total
-
Discount Validation: Use either
discount_percentORdiscount_amount, never both -
Billed Services: Once a service is billed (
is_billed: true), it cannot be updated or deleted -
Soft Delete: Deleted services are soft-deleted (status changes to
cancelled,deleted_atis set) -
Legacy Support: The API accepts both nested (
patient_service: {...}) and flat parameter formats -
Patient Facility Context: All create/list operations require a
patient_facility_idin the URL