API Response Structure Guidelines
Core Envelope
Every API response must use this envelope structure:
{
"data": <object | array | null>,
"error": <object | null>,
"meta": <object>
}Rules
-
No
successfield — HTTP status codes indicate success (2xx) or failure (4xx/5xx). The presence oferroris sufficient for client-side branching. -
datais always present — Contains the response payload on success,nullon error. -
erroris only present on failure — Never include an emptyerrorobject on success. -
metais always present — Contains request metadata. At minimum, includerequest_id. -
Use snake_case for all keys — No camelCase. This applies to field names, nested objects, and meta fields.
-
Timestamps use ISO 8601 format — Always include timezone:
2024-01-07T10:30:00Z
Success Response (Single Resource)
{
"data": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"email": "user@example.com",
"full_name": "John Doe",
"created_at": "2024-01-07T10:30:00Z",
"updated_at": "2024-01-07T10:30:00Z"
},
"meta": {
"request_id": "req_abc123"
}
}Success Response (Collection)
{
"data": [
{ "id": "...", "email": "..." },
{ "id": "...", "email": "..." }
],
"meta": {
"request_id": "req_abc123",
"pagination": {
"total_entries": 100,
"total_pages": 4,
"current_page": 1,
"per_page": 25
}
}
}Error Response
{
"data": null,
"error": {
"code": "validation_failed",
"message": "Human-readable error description",
"details": [
{ "field": "email", "message": "has already been taken" },
{ "field": "phone", "message": "is invalid format" }
]
},
"meta": {
"request_id": "req_abc123"
}
}Error Codes
Use consistent, lowercase, underscore-separated error codes:
validation_failed— Request validation errors (422)not_found— Resource does not exist (404)unauthorized— Authentication required (401)forbidden— Insufficient permissions (403)conflict— Resource state conflict (409)rate_limited— Too many requests (429)internal_error— Server error (500)
Authentication & Authorization
Authentication Methods
Support multiple authentication methods with clear precedence:
- Bearer Token (Primary) — JWT or opaque tokens
- API Key — For server-to-server integrations
- Session Cookie — For browser-based apps
Bearer Token Authentication
Request Header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...API Key Authentication
Request Header:
X-Api-Key: kulcare_live_abc123def456API key format: kulcare_{environment}_{random} where environment is live or test.
Login Flow
Request: POST /auth/login
{
"data": {
"email": "doctor@clinic.com",
"password": "securepassword123"
}
}Response: HTTP 200
{
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "ref_abc123def456",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": "2024-01-07T11:30:00Z",
"user": {
"id": "usr_789",
"email": "doctor@clinic.com",
"full_name": "Dr. Raj Patel",
"role": "provider"
}
},
"meta": {
"request_id": "req_abc123"
}
}Token Refresh
Request: POST /auth/refresh
{
"data": {
"refresh_token": "ref_abc123def456"
}
}Response: HTTP 200
{
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "ref_xyz789ghi012",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": "2024-01-07T12:30:00Z"
},
"meta": {
"request_id": "req_def456"
}
}Logout
Request: POST /auth/logout
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Response: HTTP 200
{
"data": {
"logged_out": true
},
"meta": {
"request_id": "req_ghi789"
}
}Authentication Errors
Invalid Credentials: HTTP 401
{
"data": null,
"error": {
"code": "invalid_credentials",
"message": "Email or password is incorrect"
},
"meta": {
"request_id": "req_abc123"
}
}Token Expired: HTTP 401
{
"data": null,
"error": {
"code": "token_expired",
"message": "Access token has expired",
"details": [
{ "field": "expired_at", "message": "2024-01-07T10:30:00Z" }
]
},
"meta": {
"request_id": "req_abc123"
}
}Token Invalid: HTTP 401
{
"data": null,
"error": {
"code": "token_invalid",
"message": "Access token is malformed or has been revoked"
},
"meta": {
"request_id": "req_abc123"
}
}Multi-Factor Authentication
Step 1: Login returns MFA challenge
Response: HTTP 200
{
"data": {
"mfa_required": true,
"mfa_token": "mfa_abc123",
"mfa_methods": [
{ "type": "totp", "hint": "Authenticator app" },
{ "type": "sms", "hint": "****1234" }
],
"expires_at": "2024-01-07T10:35:00Z"
},
"meta": {
"request_id": "req_abc123"
}
}Step 2: Submit MFA code
Request: POST /auth/mfa/verify
{
"data": {
"mfa_token": "mfa_abc123",
"mfa_method": "totp",
"code": "123456"
}
}Response: HTTP 200 (same as login success)
Authorization Headers
Include authorization context in response headers:
X-Auth-User-Id: usr_789
X-Auth-Facility-Id: fac_456
X-Auth-Role: provider
X-Auth-Permissions: patients:read,patients:write,encounters:readPermission Denied
Response: HTTP 403
{
"data": null,
"error": {
"code": "forbidden",
"message": "You do not have permission to perform this action",
"details": [
{ "field": "required_permission", "message": "patients:delete" },
{ "field": "your_permissions", "message": "patients:read,patients:write" }
]
},
"meta": {
"request_id": "req_abc123"
}
}Resource-Level Authorization
When access is denied to a specific resource:
Response: HTTP 403
{
"data": null,
"error": {
"code": "forbidden",
"message": "You do not have access to this patient record",
"details": [
{ "field": "resource_type", "message": "patient" },
{ "field": "resource_id", "message": "pat_789" },
{ "field": "reason", "message": "Patient belongs to different facility" }
]
},
"meta": {
"request_id": "req_abc123"
}
}Current User Endpoint
Request: GET /auth/me
Response: HTTP 200
{
"data": {
"id": "usr_789",
"email": "doctor@clinic.com",
"full_name": "Dr. Raj Patel",
"role": "provider",
"permissions": [
"patients:read",
"patients:write",
"encounters:read",
"encounters:write",
"prescriptions:write"
],
"facilities": [
{ "id": "fac_456", "name": "Downtown Clinic", "role": "admin" },
{ "id": "fac_789", "name": "Westside Medical", "role": "provider" }
],
"current_facility_id": "fac_456",
"preferences": {
"timezone": "Asia/Kolkata",
"locale": "en-IN",
"date_format": "DD/MM/YYYY"
},
"created_at": "2023-06-15T09:00:00Z",
"last_login_at": "2024-01-07T10:30:00Z"
},
"meta": {
"request_id": "req_abc123"
}
}Switch Facility Context
Request: POST /auth/switch-facility
{
"data": {
"facility_id": "fac_789"
}
}Response: HTTP 200
{
"data": {
"current_facility_id": "fac_789",
"facility": {
"id": "fac_789",
"name": "Westside Medical",
"role": "provider"
},
"permissions": [
"patients:read",
"encounters:read",
"encounters:write"
]
},
"meta": {
"request_id": "req_abc123"
}
}API Key Management
List API Keys: GET /auth/api-keys
{
"data": [
{
"id": "key_abc123",
"name": "Production Integration",
"prefix": "kulcare_live_abc",
"permissions": ["patients:read", "encounters:read"],
"last_used_at": "2024-01-07T09:15:00Z",
"created_at": "2023-12-01T10:00:00Z",
"expires_at": null
}
],
"meta": {
"request_id": "req_abc123"
}
}Create API Key: POST /auth/api-keys
{
"data": {
"name": "Mobile App Integration",
"permissions": ["patients:read", "encounters:read"],
"expires_at": "2025-01-07T00:00:00Z"
}
}Response: HTTP 201 (only time full key is shown)
{
"data": {
"id": "key_def456",
"name": "Mobile App Integration",
"key": "kulcare_live_def456ghi789jkl012mno345",
"prefix": "kulcare_live_def",
"permissions": ["patients:read", "encounters:read"],
"created_at": "2024-01-07T10:30:00Z",
"expires_at": "2025-01-07T00:00:00Z"
},
"meta": {
"request_id": "req_abc123",
"warning": "Store this key securely. It will not be shown again."
}
}Authentication Rules
-
Access tokens expire in 1 hour — Short-lived for security
-
Refresh tokens expire in 30 days — Rotate on each use
-
Revoke all tokens on password change — Force re-authentication
-
Rate limit auth endpoints aggressively — 20 requests per 15 minutes
-
Log all authentication events — Success, failure, token refresh, logout
-
Include
X-Auth-*headers in responses — Helps debugging -
Never include passwords in responses — Even masked
-
API keys are facility-scoped — Cannot access other facilities' data
-
Support token revocation — Both individual and all tokens for user
-
MFA tokens expire in 5 minutes — Short window for code entry
Webhook Delivery
Webhook Registration
Request: POST /webhooks
{
"data": {
"url": "https://myapp.com/webhooks/kulcare",
"events": [
"patient.created",
"patient.updated",
"encounter.completed",
"prescription.created"
],
"secret": "whsec_abc123def456",
"active": true
}
}Response: HTTP 201
{
"data": {
"id": "whk_789",
"url": "https://myapp.com/webhooks/kulcare",
"events": [
"patient.created",
"patient.updated",
"encounter.completed",
"prescription.created"
],
"secret_preview": "whsec_abc...456",
"active": true,
"created_at": "2024-01-07T10:30:00Z"
},
"meta": {
"request_id": "req_abc123"
}
}Webhook Payload Structure
All webhook payloads use this structure:
{
"id": "evt_abc123def456",
"type": "patient.created",
"api_version": "2.0",
"created_at": "2024-01-07T10:30:00Z",
"data": {
"object": {
"id": "pat_789",
"full_name": "Jane Smith",
"email": "jane@example.com",
"created_at": "2024-01-07T10:30:00Z"
},
"previous_attributes": null
},
"facility_id": "fac_456",
"request_id": "req_abc123"
}Event Types
| Event | Description |
|---|---|
patient.created | New patient registered |
patient.updated | Patient record modified |
patient.deleted | Patient record deleted |
encounter.created | New encounter started |
encounter.updated | Encounter modified |
encounter.completed | Encounter marked complete |
prescription.created | New prescription created |
prescription.dispensed | Prescription marked dispensed |
appointment.scheduled | New appointment booked |
appointment.cancelled | Appointment cancelled |
appointment.completed | Appointment marked complete |
invoice.created | New invoice generated |
invoice.paid | Invoice payment received |
report.ready | Async report completed |
Update Events Include Previous Values
For .updated events, include changed fields:
{
"id": "evt_def456ghi789",
"type": "patient.updated",
"api_version": "2.0",
"created_at": "2024-01-07T10:35:00Z",
"data": {
"object": {
"id": "pat_789",
"full_name": "Jane Smith-Johnson",
"email": "jane.johnson@example.com",
"phone": "+91 98765 43210",
"updated_at": "2024-01-07T10:35:00Z"
},
"previous_attributes": {
"full_name": "Jane Smith",
"email": "jane@example.com"
}
},
"facility_id": "fac_456",
"request_id": "req_def456"
}Webhook Signature
Sign all webhook payloads using HMAC-SHA256:
Headers:
X-Kulcare-Signature: sha256=abc123def456...
X-Kulcare-Timestamp: 1704625800
X-Kulcare-Event: patient.created
X-Kulcare-Delivery-Id: dlv_abc123Signature Verification (receiver side):
def verify_webhook_signature(payload, signature, timestamp, secret)
# Prevent replay attacks - reject if older than 5 minutes
return false if Time.now.to_i - timestamp.to_i > 300
signed_payload = "#{timestamp}.#{payload}"
expected_signature = "sha256=#{OpenSSL::HMAC.hexdigest('sha256', secret, signed_payload)}"
ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
endWebhook Delivery Attempts
Retry failed deliveries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
| 7 | 24 hours |
After 7 failed attempts, mark webhook as failing and notify via email.
Webhook Delivery Response
Your endpoint should return HTTP 2xx within 30 seconds. Any other response triggers retry.
Successful receipt:
HTTP/1.1 200 OKBody content is ignored.
Webhook Delivery Logs
Request: GET /webhooks/whk_789/deliveries
{
"data": [
{
"id": "dlv_abc123",
"event_id": "evt_abc123def456",
"event_type": "patient.created",
"status": "delivered",
"response_code": 200,
"response_time_ms": 245,
"attempted_at": "2024-01-07T10:30:01Z",
"delivered_at": "2024-01-07T10:30:01Z"
},
{
"id": "dlv_def456",
"event_id": "evt_def456ghi789",
"event_type": "patient.updated",
"status": "failed",
"response_code": 500,
"response_time_ms": 1523,
"error_message": "Internal Server Error",
"attempted_at": "2024-01-07T10:35:02Z",
"next_retry_at": "2024-01-07T10:36:02Z",
"retry_count": 1
}
],
"meta": {
"request_id": "req_abc123",
"pagination": {
"total_entries": 156,
"total_pages": 7,
"current_page": 1,
"per_page": 25
}
}
}Retry a Failed Delivery
Request: POST /webhooks/deliveries/dlv_def456/retry
Response: HTTP 202
{
"data": {
"id": "dlv_def456",
"status": "pending",
"retry_scheduled_at": "2024-01-07T10:40:00Z"
},
"meta": {
"request_id": "req_abc123"
}
}Test Webhook
Request: POST /webhooks/whk_789/test
{
"data": {
"event_type": "patient.created"
}
}Response: HTTP 200
{
"data": {
"delivery_id": "dlv_test_abc123",
"status": "delivered",
"response_code": 200,
"response_time_ms": 189,
"response_body": "OK"
},
"meta": {
"request_id": "req_abc123"
}
}Webhook Status Endpoint
Provide endpoint for receivers to verify webhook health:
Request: GET /webhooks/whk_789/status
{
"data": {
"id": "whk_789",
"url": "https://myapp.com/webhooks/kulcare",
"active": true,
"health": "healthy",
"success_rate_7d": 98.5,
"total_deliveries_7d": 1523,
"failed_deliveries_7d": 23,
"average_response_time_ms": 234,
"last_delivery_at": "2024-01-07T10:30:01Z",
"last_success_at": "2024-01-07T10:30:01Z",
"last_failure_at": "2024-01-06T15:22:00Z"
},
"meta": {
"request_id": "req_abc123"
}
}Webhook Rules
-
Sign all payloads — HMAC-SHA256 with shared secret
-
Include timestamp in signature — Prevents replay attacks
-
Retry with exponential backoff — Up to 7 attempts over 24 hours
-
30 second timeout — Fail fast, retry later
-
Idempotency via event ID — Receivers should deduplicate by
id -
Include API version — Receivers know payload format
-
Log all delivery attempts — Debugging and compliance
-
Disable after repeated failures — Notify admin, require manual re-enable
-
Support event filtering — Don't send events receiver doesn't want
-
Provide test endpoint — Easy integration testing
Health Check Endpoints
Basic Health Check
Request: GET /health
No authentication required.
Response (Healthy): HTTP 200
{
"data": {
"status": "healthy",
"timestamp": "2024-01-07T10:30:00Z"
},
"meta": {}
}Response (Unhealthy): HTTP 503
{
"data": {
"status": "unhealthy",
"timestamp": "2024-01-07T10:30:00Z"
},
"meta": {}
}Detailed Health Check
Request: GET /health/detailed
Requires authentication (admin or monitoring service).
Response (All Healthy): HTTP 200
{
"data": {
"status": "healthy",
"version": "2.3.1",
"environment": "production",
"timestamp": "2024-01-07T10:30:00Z",
"uptime_seconds": 864000,
"checks": {
"database": {
"status": "healthy",
"latency_ms": 2,
"details": {
"pool_size": 25,
"active_connections": 8,
"waiting_connections": 0
}
},
"redis": {
"status": "healthy",
"latency_ms": 1,
"details": {
"connected_clients": 15,
"used_memory_mb": 128
}
},
"elasticsearch": {
"status": "healthy",
"latency_ms": 5,
"details": {
"cluster_status": "green",
"number_of_nodes": 3
}
},
"sidekiq": {
"status": "healthy",
"details": {
"processes": 4,
"queued_jobs": 23,
"retry_jobs": 2,
"dead_jobs": 0,
"scheduled_jobs": 156
}
},
"storage": {
"status": "healthy",
"latency_ms": 45,
"details": {
"provider": "s3",
"region": "ap-south-1"
}
},
"external_services": {
"drug_database": {
"status": "healthy",
"latency_ms": 120
},
"sms_provider": {
"status": "healthy",
"latency_ms": 89
},
"email_provider": {
"status": "degraded",
"latency_ms": 2500,
"message": "High latency detected"
}
}
}
},
"meta": {
"request_id": "req_abc123"
}
}Response (Degraded): HTTP 200
{
"data": {
"status": "degraded",
"version": "2.3.1",
"environment": "production",
"timestamp": "2024-01-07T10:30:00Z",
"uptime_seconds": 864000,
"checks": {
"database": {
"status": "healthy",
"latency_ms": 2
},
"redis": {
"status": "healthy",
"latency_ms": 1
},
"elasticsearch": {
"status": "degraded",
"latency_ms": 850,
"message": "High latency - cluster rebalancing"
},
"sidekiq": {
"status": "degraded",
"message": "High queue depth",
"details": {
"processes": 4,
"queued_jobs": 15000,
"retry_jobs": 500
}
}
}
},
"meta": {
"request_id": "req_abc123"
}
}Response (Unhealthy): HTTP 503
{
"data": {
"status": "unhealthy",
"version": "2.3.1",
"environment": "production",
"timestamp": "2024-01-07T10:30:00Z",
"uptime_seconds": 864000,
"checks": {
"database": {
"status": "unhealthy",
"message": "Connection refused",
"error": "PG::ConnectionBad: could not connect to server"
},
"redis": {
"status": "healthy",
"latency_ms": 1
},
"elasticsearch": {
"status": "healthy",
"latency_ms": 5
}
}
},
"meta": {
"request_id": "req_abc123"
}
}Liveness Probe
For Kubernetes/container orchestration. Indicates if the process is running.
Request: GET /health/live
Response: HTTP 200
{
"data": {
"status": "alive",
"timestamp": "2024-01-07T10:30:00Z"
},
"meta": {}
}Readiness Probe
Indicates if the service is ready to accept traffic.
Request: GET /health/ready
Response (Ready): HTTP 200
{
"data": {
"status": "ready",
"timestamp": "2024-01-07T10:30:00Z"
},
"meta": {}
}Response (Not Ready): HTTP 503
{
"data": {
"status": "not_ready",
"reason": "Database migrations pending",
"timestamp": "2024-01-07T10:30:00Z"
},
"meta": {}
}Version Endpoint
Request: GET /version
Response: HTTP 200
{
"data": {
"version": "2.3.1",
"git_sha": "abc123def456",
"build_time": "2024-01-05T14:30:00Z",
"api_version": "v2",
"environment": "production"
},
"meta": {
"request_id": "req_abc123"
}
}Status Page Data
For public status page integration:
Request: GET /health/status
No authentication required. Returns minimal, safe-to-expose information.
Response: HTTP 200
{
"data": {
"status": "operational",
"components": {
"api": "operational",
"web_app": "operational",
"patient_portal": "operational",
"integrations": "degraded"
},
"incidents": [
{
"id": "inc_abc123",
"title": "Elevated API latency",
"status": "investigating",
"impact": "minor",
"created_at": "2024-01-07T10:15:00Z",
"updated_at": "2024-01-07T10:25:00Z"
}
],
"scheduled_maintenance": [
{
"id": "mnt_def456",
"title": "Database upgrade",
"status": "scheduled",
"scheduled_for": "2024-01-10T02:00:00Z",
"expected_duration_minutes": 30
}
]
},
"meta": {}
}Health Check Status Values
| Status | HTTP Code | Meaning |
|---|---|---|
healthy | 200 | All systems operational |
degraded | 200 | Some issues but functional |
unhealthy | 503 | Critical systems failing |
alive | 200 | Process is running (liveness) |
ready | 200 | Accepting traffic (readiness) |
not_ready | 503 | Not accepting traffic |
Health Check Rules
-
/healthrequires no auth — Load balancers need access -
/health/detailedrequires auth — Exposes internal details -
Liveness checks should be fast — No external dependencies
-
Readiness checks verify dependencies — Database, cache, etc.
-
Set appropriate timeouts — Health checks should respond in < 5 seconds
-
Cache health check results — Avoid overloading dependencies (cache for 5-10 seconds)
-
Include version information — Helps debugging deployments
-
Return 503 for critical failures — Load balancers can route around
-
Return 200 for degraded state — Service still usable
-
Log health check failures — Alert on repeated failures
Nested Resources & Relationships
Shallow Nesting (Default)
For belongs_to relationships, embed the foreign key and optionally a minimal reference:
{
"data": {
"id": "enc_123",
"facility_id": "fac_456",
"patient_id": "pat_789",
"encounter_type": "consultation",
"started_at": "2024-01-07T10:30:00Z"
},
"meta": {
"request_id": "req_abc123"
}
}Including Related Resources
Use the include query parameter to request related resources. Related data goes in a top-level included object, keyed by resource type.
Request: GET /encounters/enc_123?include=patient,facility,provider
Response:
{
"data": {
"id": "enc_123",
"facility_id": "fac_456",
"patient_id": "pat_789",
"provider_id": "prv_012",
"encounter_type": "consultation",
"started_at": "2024-01-07T10:30:00Z"
},
"included": {
"patient": {
"id": "pat_789",
"full_name": "Jane Smith",
"date_of_birth": "1985-03-15"
},
"facility": {
"id": "fac_456",
"name": "Downtown Clinic",
"facility_type": "clinic"
},
"provider": {
"id": "prv_012",
"full_name": "Dr. Raj Patel",
"specialty": "general_medicine"
}
},
"meta": {
"request_id": "req_abc123"
}
}Including Related Collections
For has_many relationships, the included value is an array:
Request: GET /patients/pat_789?include=encounters,prescriptions
{
"data": {
"id": "pat_789",
"full_name": "Jane Smith",
"date_of_birth": "1985-03-15"
},
"included": {
"encounters": [
{ "id": "enc_123", "encounter_type": "consultation", "started_at": "2024-01-07T10:30:00Z" },
{ "id": "enc_124", "encounter_type": "follow_up", "started_at": "2024-01-14T11:00:00Z" }
],
"prescriptions": [
{ "id": "rx_001", "medication_name": "Metformin", "status": "active" }
]
},
"meta": {
"request_id": "req_abc123"
}
}Include Rules
-
Comma-separated list —
?include=patient,facility,provider -
Dot notation for nested includes —
?include=encounter.facility(include encounter and its facility) -
Maximum include depth is 2 — Reject requests with deeper nesting
-
Only allow whitelisted includes per endpoint — Document allowed includes for each resource
-
Included resources use minimal representation — Only essential fields, not full resource detail
-
Empty relationships — Use
nullfor empty belongs_to,[]for empty has_many
{
"included": {
"referrer": null,
"attachments": []
}
}Filtering Conventions
Query Parameter Format
Use the filter parameter with bracket notation:
GET /encounters?filter[status]=completed&filter[facility_id]=fac_456Supported Filter Operators
Append operator suffix to field name:
| Operator | Suffix | Example | SQL Equivalent |
|---|---|---|---|
| Equals | (none) | filter[status]=active | status = 'active' |
| Not equals | _not | filter[status_not]=cancelled | status != 'cancelled' |
| Greater than | _gt | filter[created_at_gt]=2024-01-01 | created_at > '2024-01-01' |
| Greater or equal | _gte | filter[amount_gte]=100 | amount >= 100 |
| Less than | _lt | filter[created_at_lt]=2024-02-01 | created_at < '2024-02-01' |
| Less or equal | _lte | filter[amount_lte]=500 | amount <= 500 |
| In list | _in | filter[status_in]=active,pending | status IN ('active', 'pending') |
| Not in list | _not_in | filter[status_not_in]=cancelled,archived | status NOT IN (...) |
| Contains | _cont | filter[full_name_cont]=smith | full_name ILIKE '%smith%' |
| Starts with | _start | filter[email_start]=admin | email ILIKE 'admin%' |
| Is null | _null | filter[deleted_at_null]=true | deleted_at IS NULL |
Filter Examples
# All active encounters at a specific facility
GET /encounters?filter[status]=active&filter[facility_id]=fac_456
# Encounters created in January 2024
GET /encounters?filter[created_at_gte]=2024-01-01T00:00:00Z&filter[created_at_lt]=2024-02-01T00:00:00Z
# Patients with name containing "patel"
GET /patients?filter[full_name_cont]=patel
# Prescriptions that are active or pending
GET /prescriptions?filter[status_in]=active,pending
# Encounters without a discharge date
GET /encounters?filter[discharged_at_null]=trueSorting
Use the sort parameter with comma-separated fields. Prefix with - for descending:
# Sort by created_at descending (newest first)
GET /encounters?sort=-created_at
# Sort by status ascending, then created_at descending
GET /encounters?sort=status,-created_atSparse Fieldsets
Use the fields parameter to request specific fields only:
GET /patients?fields=id,full_name,date_of_birthResponse includes only requested fields (plus id always):
{
"data": [
{ "id": "pat_789", "full_name": "Jane Smith", "date_of_birth": "1985-03-15" }
],
"meta": { ... }
}Pagination
Use page and per_page parameters:
GET /encounters?page=2&per_page=25Defaults:
page: 1per_page: 25per_pagemaximum: 100
Combined Example
GET /encounters?filter[facility_id]=fac_456&filter[status_in]=active,completed&filter[created_at_gte]=2024-01-01&sort=-created_at&include=patient,provider&fields=id,encounter_type,started_at&page=1&per_page=50Search Conventions
Basic Search
Use the q parameter for full-text search across default searchable fields:
GET /patients?q=smithScoped Search
Combine q with filters to narrow search scope:
GET /patients?q=diabetes&filter[facility_id]=fac_456Search Response
Include search metadata in the response:
{
"data": [
{
"id": "pat_789",
"full_name": "Jane Smith",
"highlights": {
"chief_complaint": "Patient reports <em>diabetes</em> symptoms"
}
}
],
"meta": {
"request_id": "req_abc123",
"search": {
"query": "diabetes",
"total_results": 42,
"searched_fields": ["full_name", "chief_complaint", "diagnosis_notes"]
},
"pagination": {
"total_entries": 42,
"total_pages": 2,
"current_page": 1,
"per_page": 25
}
}
}Search Rules
-
qparameter for full-text search — Searches across predefined searchable fields -
Document searchable fields per resource — Clients should know which fields are searched
-
highlightsobject is optional — Include only when search matches occur in text fields -
Use
<em>tags for highlight markers — Consistent with Elasticsearch conventions -
Combine with filters and sorting — Search respects all other query parameters
-
Minimum query length: 2 characters — Reject single-character searches
-
Search is case-insensitive — Always
Advanced Search (Optional)
For complex search requirements, support field-specific search:
GET /patients?search[full_name]=smith&search[diagnosis]=diabetesBulk Operations
Bulk Create
Request: POST /patients/bulk
{
"data": [
{ "full_name": "John Doe", "email": "john@example.com" },
{ "full_name": "Jane Smith", "email": "jane@example.com" },
{ "full_name": "Invalid", "email": "not-an-email" }
]
}Response: HTTP 207 Multi-Status
{
"data": {
"results": [
{
"index": 0,
"status": "created",
"data": { "id": "pat_001", "full_name": "John Doe", "email": "john@example.com" }
},
{
"index": 1,
"status": "created",
"data": { "id": "pat_002", "full_name": "Jane Smith", "email": "jane@example.com" }
},
{
"index": 2,
"status": "failed",
"error": {
"code": "validation_failed",
"message": "Validation failed",
"details": [{ "field": "email", "message": "is invalid" }]
}
}
],
"summary": {
"total": 3,
"created": 2,
"failed": 1
}
},
"meta": {
"request_id": "req_abc123"
}
}Bulk Update
Request: PATCH /patients/bulk
{
"data": [
{ "id": "pat_001", "status": "active" },
{ "id": "pat_002", "status": "active" },
{ "id": "pat_999", "status": "active" }
]
}Response: HTTP 207 Multi-Status
{
"data": {
"results": [
{ "index": 0, "id": "pat_001", "status": "updated" },
{ "index": 1, "id": "pat_002", "status": "updated" },
{
"index": 2,
"id": "pat_999",
"status": "failed",
"error": { "code": "not_found", "message": "Patient not found" }
}
],
"summary": {
"total": 3,
"updated": 2,
"failed": 1
}
},
"meta": {
"request_id": "req_abc123"
}
}Bulk Delete
Request: DELETE /patients/bulk
{
"data": {
"ids": ["pat_001", "pat_002", "pat_003"]
}
}Response: HTTP 207 Multi-Status
{
"data": {
"results": [
{ "index": 0, "id": "pat_001", "status": "deleted" },
{ "index": 1, "id": "pat_002", "status": "deleted" },
{
"index": 2,
"id": "pat_003",
"status": "failed",
"error": { "code": "conflict", "message": "Patient has active encounters" }
}
],
"summary": {
"total": 3,
"deleted": 2,
"failed": 1
}
},
"meta": {
"request_id": "req_abc123"
}
}Bulk Operation Rules
-
Always return HTTP 207 Multi-Status — Even if all operations succeed or fail
-
Include
indexin each result — Maps response back to request array position -
Process all items — Don't stop on first failure; report all results
-
Include
summaryobject — Quick overview without parsing all results -
Maximum batch size: 100 items — Reject larger batches with 413 Payload Too Large
-
Bulk endpoints use
/resource/bulkpath — Clear distinction from single-resource endpoints -
Atomic option — Support
?atomic=trueto rollback all on any failure
POST /patients/bulk?atomic=trueWith atomic=true, return standard error response on any failure:
{
"data": null,
"error": {
"code": "bulk_operation_failed",
"message": "Bulk operation rolled back due to failures",
"details": [
{ "index": 2, "field": "email", "message": "is invalid" }
]
},
"meta": {
"request_id": "req_abc123"
}
}Async Job Responses
Initiating an Async Job
For long-running operations, return HTTP 202 Accepted with job info:
Request: POST /reports/generate
{
"data": {
"report_type": "patient_summary",
"facility_id": "fac_456",
"date_range": {
"start": "2024-01-01",
"end": "2024-01-31"
}
}
}Response: HTTP 202 Accepted
{
"data": {
"job_id": "job_abc123",
"status": "pending",
"created_at": "2024-01-07T10:30:00Z",
"estimated_completion": "2024-01-07T10:32:00Z",
"poll_url": "/jobs/job_abc123",
"webhook_url": null
},
"meta": {
"request_id": "req_abc123"
}
}Polling Job Status
Request: GET /jobs/job_abc123
Response (In Progress): HTTP 200
{
"data": {
"job_id": "job_abc123",
"status": "processing",
"progress": {
"current": 450,
"total": 1000,
"percentage": 45
},
"created_at": "2024-01-07T10:30:00Z",
"started_at": "2024-01-07T10:30:05Z",
"estimated_completion": "2024-01-07T10:32:00Z"
},
"meta": {
"request_id": "req_def456"
}
}Response (Completed): HTTP 200
{
"data": {
"job_id": "job_abc123",
"status": "completed",
"progress": {
"current": 1000,
"total": 1000,
"percentage": 100
},
"created_at": "2024-01-07T10:30:00Z",
"started_at": "2024-01-07T10:30:05Z",
"completed_at": "2024-01-07T10:31:45Z",
"result": {
"download_url": "/reports/rpt_xyz789/download",
"expires_at": "2024-01-08T10:31:45Z",
"file_size": 245678,
"file_type": "application/pdf"
}
},
"meta": {
"request_id": "req_ghi789"
}
}Response (Failed): HTTP 200
{
"data": {
"job_id": "job_abc123",
"status": "failed",
"created_at": "2024-01-07T10:30:00Z",
"started_at": "2024-01-07T10:30:05Z",
"failed_at": "2024-01-07T10:30:15Z",
"error": {
"code": "generation_failed",
"message": "Report generation failed due to missing data",
"details": [
{ "field": "facility_id", "message": "Facility has no patients in date range" }
]
}
},
"meta": {
"request_id": "req_jkl012"
}
}Job Status Values
pending— Job created, waiting to startprocessing— Job is actively runningcompleted— Job finished successfullyfailed— Job failed with errorcancelled— Job was cancelled by user
Cancelling a Job
Request: POST /jobs/job_abc123/cancel
Response: HTTP 200
{
"data": {
"job_id": "job_abc123",
"status": "cancelled",
"cancelled_at": "2024-01-07T10:31:00Z"
},
"meta": {
"request_id": "req_mno345"
}
}Webhook Notifications (Optional)
If client provides a webhook URL when creating the job:
Request: POST /reports/generate
{
"data": {
"report_type": "patient_summary",
"facility_id": "fac_456",
"webhook_url": "https://myapp.com/webhooks/job-complete"
}
}Webhook Payload (on completion):
{
"event": "job.completed",
"job_id": "job_abc123",
"status": "completed",
"completed_at": "2024-01-07T10:31:45Z",
"result": {
"download_url": "https://api.kulcare.com/reports/rpt_xyz789/download",
"expires_at": "2024-01-08T10:31:45Z"
}
}Async Job Rules
-
Return HTTP 202 for accepted async jobs — Not 200 or 201
-
Always include
poll_url— Clients need a clear path to check status -
Include
estimated_completionwhen possible — Helps clients decide polling frequency -
progressobject is optional — Include only when progress is trackable -
Job status endpoint always returns HTTP 200 — Even for failed jobs; the
statusfield indicates outcome -
Include error details in failed jobs — Same structure as standard error responses
-
Result URLs should be time-limited — Include
expires_atfor download links -
Job results are retained for 24 hours — Document retention policy
-
Support webhook callbacks — For clients that prefer push over poll
-
Idempotency key for job creation — Support
Idempotency-Keyheader to prevent duplicate jobs
POST /reports/generate
Idempotency-Key: user_report_20240107_fac456API Versioning
URL Path Versioning
Use version prefix in URL path:
GET /v2/patients/pat_789
GET /v2/encounters?filter[status]=activeVersion Rules
-
Major version in URL —
/v1/,/v2/, etc. -
No version means latest stable —
/patientsredirects to current version (not recommended for production clients) -
Version is required for all production integrations — Enforce via documentation and API keys
-
Maintain previous version for minimum 12 months after deprecation announcement
-
Use
Api-Versionheader for minor/patch information:
Api-Version: 2.3.1Deprecation Headers
When a version or endpoint is deprecated, include headers:
Deprecation: true
Sunset: Sat, 01 Jan 2026 00:00:00 GMT
Link: <https://api.kulcare.com/v3/patients>; rel="successor-version"Version Response
Include version info in meta:
{
"data": { ... },
"meta": {
"request_id": "req_abc123",
"api_version": "2.3.1"
}
}Breaking vs Non-Breaking Changes
Non-breaking (no version bump required):
- Adding new optional fields to responses
- Adding new optional query parameters
- Adding new endpoints
- Adding new error codes
Breaking (requires version bump):
- Removing or renaming fields
- Changing field types
- Removing endpoints
- Changing authentication requirements
- Changing default behavior
Rate Limiting
Rate Limit Headers
Include in every response:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 998
X-RateLimit-Reset: 1704628800
X-RateLimit-Window: 3600| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests allowed in window |
X-RateLimit-Remaining | Requests remaining in current window |
X-RateLimit-Reset | Unix timestamp when window resets |
X-RateLimit-Window | Window duration in seconds |
Rate Limit Response
When limit exceeded, return HTTP 429:
{
"data": null,
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded. Try again in 342 seconds.",
"details": [
{ "field": "retry_after", "message": "342" }
]
},
"meta": {
"request_id": "req_abc123"
}
}Include Retry-After header:
Retry-After: 342Rate Limit Tiers
Define different limits by endpoint type:
| Tier | Limit | Window | Endpoints |
|---|---|---|---|
| Standard | 1000 | 1 hour | Most endpoints |
| Search | 100 | 1 minute | /*/search, ?q= queries |
| Bulk | 50 | 1 hour | /*/bulk endpoints |
| Auth | 20 | 15 minutes | /auth/* endpoints |
| Reports | 10 | 1 hour | /reports/generate |
Rate Limit Rules
-
Always include rate limit headers — Even when not rate limited
-
Rate limit by API key, not IP — Supports legitimate multi-server deployments
-
Separate limits for different endpoint tiers — Bulk operations more restrictive
-
Include
Retry-Afteron 429 responses — Both header and in error details -
Log rate limit hits — Monitor for abuse and adjust limits
-
Provide rate limit status endpoint:
GET /rate_limit{
"data": {
"limits": {
"standard": { "limit": 1000, "remaining": 998, "reset": 1704628800 },
"search": { "limit": 100, "remaining": 95, "reset": 1704625260 },
"bulk": { "limit": 50, "remaining": 50, "reset": 1704628800 }
}
},
"meta": {
"request_id": "req_abc123"
}
}File Upload Conventions
Direct Upload (Small Files < 10MB)
Request: POST /attachments
Content-Type: multipart/form-data------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="lab_report.pdf"
Content-Type: application/pdf
<binary data>
------WebKitFormBoundary
Content-Disposition: form-data; name="encounter_id"
enc_123
------WebKitFormBoundary
Content-Disposition: form-data; name="attachment_type"
lab_result
------WebKitFormBoundary--Response: HTTP 201 Created
{
"data": {
"id": "att_789",
"filename": "lab_report.pdf",
"content_type": "application/pdf",
"file_size": 245678,
"encounter_id": "enc_123",
"attachment_type": "lab_result",
"download_url": "/attachments/att_789/download",
"created_at": "2024-01-07T10:30:00Z"
},
"meta": {
"request_id": "req_abc123"
}
}Presigned Upload (Large Files > 10MB)
Step 1: Request Upload URL
Request: POST /attachments/presign
{
"data": {
"filename": "mri_scan.dcm",
"content_type": "application/dicom",
"file_size": 52428800,
"encounter_id": "enc_123",
"attachment_type": "imaging"
}
}Response: HTTP 200
{
"data": {
"upload_id": "upl_abc123",
"upload_url": "https://storage.kulcare.com/uploads/upl_abc123?signature=...",
"upload_method": "PUT",
"upload_headers": {
"Content-Type": "application/dicom",
"x-amz-meta-upload-id": "upl_abc123"
},
"expires_at": "2024-01-07T11:30:00Z"
},
"meta": {
"request_id": "req_abc123"
}
}Step 2: Upload to Presigned URL
Client uploads directly to storage:
PUT https://storage.kulcare.com/uploads/upl_abc123?signature=...
Content-Type: application/dicom
x-amz-meta-upload-id: upl_abc123
<binary data>Step 3: Confirm Upload
Request: POST /attachments/presign/upl_abc123/complete
{
"data": {
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\""
}
}Response: HTTP 201 Created
{
"data": {
"id": "att_790",
"filename": "mri_scan.dcm",
"content_type": "application/dicom",
"file_size": 52428800,
"encounter_id": "enc_123",
"attachment_type": "imaging",
"download_url": "/attachments/att_790/download",
"created_at": "2024-01-07T10:35:00Z"
},
"meta": {
"request_id": "req_def456"
}
}Multipart Upload (Very Large Files > 100MB)
Step 1: Initiate Multipart Upload
Request: POST /attachments/multipart
{
"data": {
"filename": "ct_scan_series.zip",
"content_type": "application/zip",
"file_size": 524288000,
"encounter_id": "enc_123",
"attachment_type": "imaging"
}
}Response: HTTP 200
{
"data": {
"upload_id": "mpu_xyz789",
"part_size": 10485760,
"total_parts": 50,
"parts": [
{
"part_number": 1,
"upload_url": "https://storage.kulcare.com/uploads/mpu_xyz789/1?signature=...",
"expires_at": "2024-01-07T11:30:00Z"
},
{
"part_number": 2,
"upload_url": "https://storage.kulcare.com/uploads/mpu_xyz789/2?signature=...",
"expires_at": "2024-01-07T11:30:00Z"
}
],
"next_parts_url": "/attachments/multipart/mpu_xyz789/parts?from=3"
},
"meta": {
"request_id": "req_abc123"
}
}Step 2: Upload Parts
Client uploads each part and collects ETags:
PUT https://storage.kulcare.com/uploads/mpu_xyz789/1?signature=...
Content-Length: 10485760
<binary data for part 1>Response header: ETag: "abc123..."
Step 3: Complete Multipart Upload
Request: POST /attachments/multipart/mpu_xyz789/complete
{
"data": {
"parts": [
{ "part_number": 1, "etag": "\"abc123...\"" },
{ "part_number": 2, "etag": "\"def456...\"" },
{ "part_number": 3, "etag": "\"ghi789...\"" }
]
}
}Response: HTTP 201 Created
{
"data": {
"id": "att_791",
"filename": "ct_scan_series.zip",
"content_type": "application/zip",
"file_size": 524288000,
"encounter_id": "enc_123",
"attachment_type": "imaging",
"download_url": "/attachments/att_791/download",
"created_at": "2024-01-07T10:45:00Z"
},
"meta": {
"request_id": "req_ghi789"
}
}File Download
Request: GET /attachments/att_789/download
Response: HTTP 302 Redirect
Location: https://storage.kulcare.com/files/att_789?signature=...&expires=1704632400Or for inline viewing:
Request: GET /attachments/att_789/download?disposition=inline
File Upload Rules
-
Direct upload for files < 10MB — Simpler for small files
-
Presigned upload for 10MB - 100MB — Offloads bandwidth from API server
-
Multipart for > 100MB — Enables resumable uploads
-
Validate file types — Whitelist allowed MIME types per attachment_type
-
Scan for malware — Queue uploaded files for scanning before making available
-
Generate thumbnails for images — Include
thumbnail_urlin response -
Time-limited download URLs — 1 hour expiry default
-
Include file metadata in response:
{
"data": {
"id": "att_789",
"filename": "lab_report.pdf",
"content_type": "application/pdf",
"file_size": 245678,
"checksum": {
"algorithm": "sha256",
"value": "abc123def456..."
},
"dimensions": null,
"thumbnail_url": null,
"download_url": "/attachments/att_789/download",
"created_at": "2024-01-07T10:30:00Z"
}
}- Support resumable downloads:
GET /attachments/att_789/download
Range: bytes=1000-1999Response: HTTP 206 Partial Content
Content-Range: bytes 1000-1999/245678Allowed File Types by Category
| Category | Allowed MIME Types | Max Size |
|---|---|---|
document | application/pdf, application/msword, application/vnd.openxmlformats-* | 25MB |
image | image/jpeg, image/png, image/gif, image/webp | 10MB |
lab_result | application/pdf, image/jpeg, image/png | 25MB |
imaging | application/dicom, application/zip | 500MB |
audio | audio/mpeg, audio/wav, audio/ogg | 100MB |
video | video/mp4, video/webm | 500MB |
Implementation Notes
-
Create a base response builder/serializer — Enforces envelope structure across all endpoints
-
Whitelist filters per resource — Never allow arbitrary filter fields; explicitly declare what's filterable
-
Whitelist sortable fields — Prevent sorting on unindexed columns
-
Whitelist includable relationships — Document and enforce per endpoint
-
Validate filter values — Type-check and sanitize all filter inputs
-
Generate
request_idfor every request — Log it server-side, return it to client -
Never return raw ActiveRecord errors — Transform to consistent error structure
-
Empty collections return
[]— Nevernullfor collections -
Index your filter columns — Any field exposed for filtering should have a database index
-
Consider caching includes separately — Included resources can be cached independently
-
Use background job library — Sidekiq or similar for async operations
-
Store job state in Redis or database — Jobs must survive server restarts
-
Implement job cleanup — Purge old job records and results
-
Rate limit bulk endpoints separately — More restrictive than single-resource endpoints
-
Log all bulk operations — Audit trail for batch changes
-
Use cloud storage for file uploads — S3, GCS, or Azure Blob with presigned URLs
-
Implement virus scanning — ClamAV or cloud-based scanning for all uploads
-
Version your API from day one — Easier than retrofitting later
-
Monitor rate limit hits — Adjust limits based on real usage patterns
-
Document everything — Each endpoint should list allowed filters, includes, sort fields
-
Use secure token storage — Never store plaintext tokens; use hashed values
-
Implement token rotation — Refresh tokens should be single-use
-
Log all authentication events — Success, failure, logout for audit trail
-
Sign all webhook payloads — HMAC-SHA256 with timestamp
-
Implement webhook retry with backoff — Exponential backoff up to 24 hours
-
Health checks should be lightweight — Don't overload dependencies
-
Separate liveness from readiness — Different purposes for orchestration