Open-source PHP library for integrating with SATUSEHAT β Indonesia's national health data platform powered by FHIR R4. Works standalone (native PHP) or with Laravel.
satusehat-integration is an open-source PHP library for integrating with SATUSEHAT β Indonesia's national health data platform powered by FHIR R4.
Built on the official SATUSEHAT Platform Guidelines. Ships with:
- 32 DataType classes β composable FHIR R4 value objects
- 51 PayloadBuilder classes β fluent builders for all FHIR resources
- SSRequest / SSResponse β HTTP client with OAuth2 + retry
- Durable queue + worker β SQLite-backed, retry/DLQ, rate limiting, monitoring
- SSValidationError β maps 558 SATUSEHAT validation rule codes to human messages
- Master data: ICD-10, Kode Wilayah Indonesia, KFA v2
Deploy as standalone PHP or integrate with Laravel via the service provider.
composer require ivanwilliammd/satusehat-integration# .env
SATUSEHAT_ENV=DEV # DEV | STG | PROD
SATUSEHAT_BASE_URL_DEV=https://api-satusehat-dev.dto.kemkes.go.id
CLIENTID_DEV=your_client_id
CLIENTSECRET_DEV=your_client_secret
ORGID_DEV=your_org_idTerminologyClient menghubungkan aplikasi ke SmarTerm β layanan kamus terpadu
terminologi kesehatan Indonesia (ICD-10, ICD-9-CM, SNOMED CT, LOINC, KPTL, KFA
obat/alkes/BZA). Berguna saat aplikasi TIDAK ingin menyimpan dataset nasional
lokal β cukup panggil REST API untuk search / validate / resolve FHIR code.
TERMINOLOGY_BASE_URL=https://terminology.ivanmd.id # opsional
# Auth β pilih SALAH SATU:
TERMINOLOGY_API_TOKEN=your_pat # 1) Sanctum PAT (statis)
TERMINOLOGY_CLIENT_ID=your_oidc_client # 2) OIDC client_credentials
TERMINOLOGY_CLIENT_SECRET=your_oidc_secret # ke auth.ivanmd.id (auto refresh)
TERMINOLOGY_TOKEN_URL=https://auth.ivanmd.id/oauth/token # defaultuse Satusehat\Integration\TerminologyClient;
// PAT statis (atau kosongkan kalau pakai env OIDC β auto-fetch + cache + refresh)
$term = new TerminologyClient('https://terminology.ivanmd.id', 'sanctum-pat-...');
// Fluent chaining β istilah bebas RS β kode resmi
$result = $term->query('pemasangan kateter')->on('kptl')->limit(10)->get();
$result = $term->query('demam berdarah')->category('diagnosis')->first()->get();
$result = $term->query('93004944')->asCode()->get(); // validasi kode, sistem ditebak (93βkfa)
$result = $term->query('91001834')->on('kfa_bza')->asCode()->get(); // paksa sistem
// Atau method langsung:
$result = $term->search('demam berdarah', 'diagnosis', 10); // unified
$alkes = $term->searchSystem('kfa_alkes', 'termometer', 5); // per sistem
$ok = $term->validate('snomed', '44054006'); // { valid, display, active }
$cond = $term->resolveCondition('E11', 'icd10', 'encounter-diagnosis');
$med = $term->resolveMedication('93004944'); // KFA obat
$kptl = $term->kptlCombine('10026', ['OR032', 'PD002']); // KPTL multi-layer
$map = $term->map('icd10', 'snomed', 'E11'); // lintas terminologiAtomic FHIR R4 value objects. All extend DataType which provides a recursive toArray() method β nested DataType instances serialize to clean FHIR JSON automatically.
| Category | Classes |
|---|---|
| Core | Coding, CodeableConcept, Identifier, Period, ContactPoint, Address, HumanName, Reference |
| Quantity | Quantity, SimpleQuantity, Range, Ratio, Age, Count, Distance, Duration, Money |
| Structured | Attachment, Narrative, Annotation, Timing, TimingRepeat, Dosage, DosageDoseAndRate |
| Utility | Extension, Signature, RelatedArtifact, Expression, TriggerDefinition, DataRequirement, ParameterDefinition |
Example β HumanName:
use Satusehat\Integration\DataType\HumanName;
$name = new HumanName(
family: 'Doe',
given: ['John', 'Michael'],
use: 'official'
);
// $name->toArray() β ['family' => 'Doe', 'given' => ['John', 'Michael'], 'use' => 'official']Fluent builder for each FHIR resource. Each builder accepts DataType instances and exposes a build() method returning a clean FHIR JSON payload.
$patient = (new PayloadBuilderPatient)
->setId('12345678-1234-1234-1234-123456789012')
->addIdentifier($identifier)
->addName($name)
->setGender('male')
->setBirthDate('1990-01-15')
->addAddress($address)
->addTelecom($phone)
->build();- SSRequest β HTTP client with
get(),post(),put(),delete()methods. Handles OAuth2 bearer tokens, auto-refresh on HTTP 401, retry with exponential backoff on 429/5xx, configurable timeout. - SSResponse β Structured response wrapper:
isSuccess()/isError(),getErrorMessages(),getResourceId().
use Satusehat\Integration\SSRequest\SSRequest;
use Satusehat\Integration\OAuth2Client;
$oauth2 = new OAuth2Client();
$ss = new SSRequest($oauth2);
$resp = $ss->post('Patient', $patientPayload);
if ($resp->isSuccess()) {
$patientId = $resp->getResourceId();
} else {
foreach ($resp->getErrorMessages() as $msg) {
// handle error
}
}Durable SQLite queue with background worker. Handles retry, DLQ, rate limiting, and monitoring. Works standalone (no Laravel needed).
use Satusehat\Integration\Queue\SqliteQueue;
use Satusehat\Integration\Queue\Worker;
use Satusehat\Integration\Queue\RateLimiter;
use Satusehat\Integration\Queue\QueueMonitor;
// Setup
$pdo = new PDO('sqlite:' . __DIR__ . '/queue.db');
$queue = new SqliteQueue($pdo);
// Enqueue a FHIR resource
$job = $queue->enqueue(
method: 'POST',
resourceType: 'Patient',
url: 'Patient',
payload: $patientPayload,
idempotencyKey: 'patient:12345:create',
userId: 'system',
);
// Process with worker (300 RPM rate limit)
$worker = new Worker($queue, [
'client_id' => $_ENV['SS_CLIENT_ID'],
'client_secret' => $_ENV['SS_CLIENT_SECRET'],
'base_url' => 'https://api-satusehat-stg.dto.kemkes.go.id',
'fhir_url' => 'https://api-satusehat-stg.dto.kemkes.go.id/fhir-r4/v1',
], new RateLimiter(300));
$result = $worker->process(50); // process up to 50 jobs
print_r($result);
// Monitor
$monitor = new QueueMonitor($queue);
print_r($monitor->healthCheck());Status flow: pending β processing β success | failed (auto-retry) | dlq
Error classification: 401 (retry+token refresh), 429 (honor Retry-After), 422/400/403/404/409/412 (DLQ), 5xx (retry)
Artisan commands (Laravel):
php artisan ss:enqueue POST Patient --payload='{}'
php artisan ss:worker --batch=50
php artisan ss:stats
php artisan ss:dead-letters
php artisan ss:requeue {id}use Satusehat\Integration\SSRequest\SSRequest;
use Satusehat\Integration\OAuth2Client;
use Satusehat\Integration\DataType\Identifier;
use Satusehat\Integration\DataType\HumanName;
use Satusehat\Integration\DataType\Address;
use Satusehat\Integration\DataType\ContactPoint;
use Satusehat\Integration\DataType\CodeableConcept;
use Satusehat\Integration\DataType\Period;
use Satusehat\Integration\Builder\PayloadBuilderPatient;
$ss = new SSRequest(new OAuth2Client());
// Compose DataType objects
$identifier = new Identifier(
system: 'https://fhir.kemkes.go.id/id/NIK',
value: '3312345678901234'
);
$name = new HumanName(
family: 'Doe',
given: ['John'],
use: 'official'
);
$phone = new ContactPoint(
system: 'phone',
value: '081234567890',
use: 'mobile'
);
$address = new Address(
use: 'home',
line: ['Jl. Sudirman No.1'],
city: 'Jakarta Selatan',
district: 'Kebayoran Baru',
state: 'DKI Jakarta',
postalCode: '12190',
country: 'ID'
);
// Build Patient resource
$patient = (new PayloadBuilderPatient)
->addIdentifier($identifier)
->addName($name)
->setGender('male')
->setBirthDate('1990-01-15')
->addTelecom($phone)
->addAddress($address)
->setActive(true)
->build();
$resp = $ss->post('Patient', $patient);use Satusehat\Integration\DataType\Reference;
use Satusehat\Integration\DataType\CodeableConcept;
use Satusehat\Integration\DataType\Period;
use Satusehat\Integration\Builder\PayloadBuilderEncounter;
$subject = new Reference(
reference: "Patient/{$patientId}",
display: 'John Doe'
);
$participant = new Reference(
reference: 'Practitioner/10009880728',
display: 'Dr. Smith'
);
$class = new CodeableConcept(
coding: [new \Satusehat\Integration\DataType\Coding(
system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode',
code: 'AMB',
display: 'ambulatory'
)]
);
$encounter = (new PayloadBuilderEncounter)
->setStatus('finished')
->setClass($class)
->setSubject($subject)
->addParticipantIndividual($participant)
->setPeriodStart(now()->toIso8601String())
->addReasonText('Pemeriksaan umum')
->build();
$resp = $ss->post('Encounter', $encounter);use Satusehat\Integration\DataType\CodeableConcept;
use Satusehat\Integration\DataType\Coding;
use Satusehat\Integration\DataType\Reference;
use Satusehat\Integration\DataType\Quantity;
use Satusehat\Integration\DataType\Annotation;
use Satusehat\Integration\Builder\PayloadBuilderObservation;
$category = new CodeableConcept(
coding: [new Coding(
system: 'http://terminology.hl7.org/CodeSystem/observation-category',
code: 'vital-signs',
display: 'Vital Signs'
)]
);
$code = new CodeableConcept(
coding: [new Coding(
system: 'http://loinc.org',
code: '8867-4',
display: 'Heart rate'
)]
);
$value = new Quantity(
value: 72,
unit: 'beats/minute',
system: 'http://unitsofmeasure.org',
code: '/min'
);
$observation = (new PayloadBuilderObservation)
->setStatus('final')
->addCategory($category)
->setCode($code)
->setSubject($subject)
->setEncounter($encounterRef)
->setEffectiveDateTime(now()->toIso8601String())
->setValueQuantity($value)
->addReferenceRange(
low: new Quantity(value: 60, unit: 'bpm', system: 'http://unitsofmeasure.org', code: '/min'),
high: new Quantity(value: 100, unit: 'bpm', system: 'http://unitsofmeasure.org', code: '/min'),
text: '60-100 bpm'
)
->build();
$resp = $ss->post('Observation', $observation);use Satusehat\Integration\OAuth2Client;
$client = new OAuth2Client();
$patient = [
'resourceType' => 'Patient',
'identifier' => [['system' => '...', 'value' => '...']],
'name' => [['family' => 'Doe', 'given' => ['John'], 'use' => 'official']],
// ... manually build every nested structure
];
[$status, $resp] = $client->ss_post('Patient', $patient);
// Check response by inspecting raw array
if ($status >= 200 && $status < 300) {
$id = $resp['id'] ?? null;
}use Satusehat\Integration\SSRequest\SSRequest;
use Satusehat\Integration\OAuth2Client;
use Satusehat\Integration\DataType\Identifier;
use Satusehat\Integration\DataType\HumanName;
use Satusehat\Integration\Builder\PayloadBuilderPatient;
$ss = new SSRequest(new OAuth2Client());
$patient = (new PayloadBuilderPatient)
->addIdentifier(new Identifier(system: '...', value: '...'))
->addName(new HumanName(family: 'Doe', given: ['John'], use: 'official'))
->setGender('male')
->build();
$resp = $ss->post('Patient', $patient);
if ($resp->isSuccess()) {
$id = $resp->getResourceId();
} else {
foreach ($resp->getErrorMessages() as $msg) { /* log */ }
}Key improvements in v4:
- DataType classes guarantee valid FHIR structure
toArray()handles nested serialization recursively- SSResponse gives typed, structured access to responses
- Automatic token refresh and retry on network failures
- Fully fluent builder API
All 51 resources fully implemented via PayloadBuilder classes. Core (β ) + Non-Core (πΌ):
| # | Resource | GET | POST | PUT | PATCH | Notes |
|---|---|---|---|---|---|---|
| 1 | Patient | β | β | β | β | MPI |
| 2 | Practitioner | β | β | β | β | SDMK |
| 3 | PractitionerRole | β | β | β | β | |
| 4 | Organization | β | β | β | β | MSI |
| 5 | Location | β | β | β | β | |
| 6 | Encounter | β | β | β | β | |
| 7 | Condition | β | β | β | β | |
| 8 | Observation | β | β | β | β | |
| 9 | Procedure | β | β | β | β | |
| 10 | MedicationRequest | β | β | β | β | |
| 11 | Bundle | β | β | β | β | batch/transaction |
| 12 | CarePlan | β | β | β | β | |
| 13 | Composition | β | β | β | β | RME |
| 14 | ClinicalImpression | β | β | β | β | |
| 15 | Goal | β | β | β | β | |
| 16 | NutritionOrder | β | β | β | β | |
| 17 | AllergyIntolerance | β | β | β | β | |
| 18 | Device | β | β | β | β | |
| 19 | DiagnosticReport | β | β | β | β | |
| 20 | DocumentReference | β | β | β | β | |
| 21 | EpisodeOfCare | β | β | β | β | |
| 22 | FamilyMemberHistory | β | β | β | β | |
| 23 | GenomicStudy | β | β | β | β | |
| 24 | Group | β | β | β | β | |
| 25 | Immunization | β | β | β | β | |
| 26 | Medication | β | β | β | β | |
| 27 | MedicationAdministration | β | β | β | β | |
| 28 | MedicationDispense | β | β | β | β | |
| 29 | MedicationStatement | β | β | β | β | |
| 30 | MolecularSequence | β | β | β | β | |
| 31 | QuestionnaireResponse | β | β | β | β | |
| 32 | RelatedPerson | β | β | β | β | |
| 33 | RiskAssessment | β | β | β | β | |
| 34 | ServiceRequest | β | β | β | β | |
| 35 | Specimen | β | β | β | β | |
| 36 | Substance | β | β | β | β | |
| 37 | Task | β | β | β | β | |
| 38 | Account | β | β | β | β | |
| 39 | ImagingStudy | πΌ | πΌ | πΌ | πΌ | non-core |
| 40 | Coverage | πΌ | πΌ | πΌ | πΌ | non-core |
| 41 | CoverageEligibilityRequest | πΌ | πΌ | πΌ | πΌ | non-core |
| 42 | CoverageEligibilityResponse | πΌ | πΌ | πΌ | πΌ | non-core |
| 43 | Claim | πΌ | πΌ | πΌ | πΌ | non-core |
| 44 | ClaimResponse | πΌ | πΌ | πΌ | πΌ | non-core |
| 45 | ChargeItem | πΌ | πΌ | πΌ | πΌ | non-core |
| 46 | ChargeItemDefinition | πΌ | πΌ | πΌ | πΌ | non-core |
| 47 | ChargeItemResponse | πΌ | πΌ | πΌ | πΌ | non-core |
| 48 | PaymentNotice | πΌ | πΌ | πΌ | πΌ | non-core |
| 49 | PaymentReconciliation | πΌ | πΌ | πΌ | πΌ | non-core |
| 50 | Invoice | πΌ | πΌ | πΌ | πΌ | non-core |
| Page | Description |
|---|---|
| CHANGELOG | Version history and release notes |
| Wiki | Full documentation |
| Installation | composer require, publish config, env setup |
| Usage | OAuth, Patient, Encounter, Condition, Bundle, KFA |
| Features | Full feature matrix |
| Onboarding | SATUSEHAT developer account setup |
| ROADMAP.md | Phased release plan v3.x β v5.0 |
- HL7 FHIR R4 Specification
- SATUSEHAT Platform Docs
- SATUSEHAT FHIR Base URL
- satusehat-laravel-example β Laravel 10 full integration example
Contributions are welcome. See CONTRIBUTING.md for guidelines.
Open an issue for bugs or feature requests.
MIT β see LICENSE.