banguncode / php-frista
A simple PHP library for BPJS Kesehatan facial recognition integration.
Requires
- php: >=5.5
- ext-curl: *
- ext-openssl: *
README
PHPFrista is a PHP library for integrating with BPJS Kesehatan FRISTA (Facial Recognition Integrated System).
It covers the full biometric flow: authentication, face verification, and first-time enrollment.
Features
- Token-based authentication with server-side session validation — stale tokens are detected and refreshed automatically.
- Face identification from encoding alone, no ID required (
/face/recognition2). - Participant lookup by NIK (
/face/nik2). - Face verification by ID + encoding (
/face/match2) with all documented server responses mapped toStatusCodeconstants. - Biometric enrollment from a JPEG file, base64 string, or encoding array (
/face/upload,/face/upload2). - Input validation for identity number, encoding format, and image type.
- Single shared cURL wrapper — no duplicated request boilerplate.
Important
navigator.mediaDevices.getUserMedia() only works on localhost or over HTTPS.
For capturing face descriptors in the browser, face-api.js works well with the FRISTA encoding format. The detection.descriptor array from withFaceDescriptor() is a 128-element Float32Array that maps directly to the $encoding parameter.
Example capture page:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>Face Capture</title> <style> body { font-family: Arial; margin: 20px; } #videoWrap { position: relative; width: 640px; } video { width: 100%; background: #000; transform: scaleX(-1); } canvas { position: absolute; left: 0; top: 0; transform: scaleX(-1); } #capturedImage { max-width: 640px; margin-top: 20px; display: none; } button { padding: 8px 12px; margin: 10px 5px 0 0; } #log { margin-top: 10px; padding: 10px; background: #f5f5f5; font-family: monospace; font-size: 12px; max-height: 300px; overflow: auto; } </style> </head> <body> <h2>Face Capture</h2> <div id="videoWrap"> <video id="video" autoplay muted playsinline></video> <canvas id="overlay"></canvas> </div> <button id="btnCapture" disabled>Capture</button> <img id="capturedImage" /> <pre id="log"></pre> <script src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js"></script> <script> const video = document.getElementById('video'); const logEl = document.getElementById('log'); const btnCapture = document.getElementById('btnCapture'); const capturedImage = document.getElementById('capturedImage'); function log(msg) { logEl.innerHTML += msg + '\n'; } async function init() { try { log('Loading models...'); await faceapi.nets.ssdMobilenetv1.loadFromUri('./models/ssd_mobilenetv1'); await faceapi.nets.faceLandmark68Net.loadFromUri('./models/face_landmark_68'); await faceapi.nets.faceRecognitionNet.loadFromUri('./models/face_recognition'); log('Models loaded'); const stream = await navigator.mediaDevices.getUserMedia({ video: true }); video.srcObject = stream; video.addEventListener('loadedmetadata', () => { log('Camera ready'); btnCapture.disabled = false; }); } catch (e) { log('Error: ' + e.message); } } async function captureAndDetect() { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); // Mirror the frame to match the mirrored video display ctx.translate(canvas.width, 0); ctx.scale(-1, 1); ctx.drawImage(video, 0, 0); capturedImage.src = canvas.toDataURL(); capturedImage.style.display = 'block'; log('Detecting face...'); const detection = await faceapi .detectSingleFace(canvas) .withFaceLandmarks() .withFaceDescriptor(); if (!detection) { log('No face detected'); return; } log('Score: ' + detection.detection.score.toFixed(2)); log('Encoding: ' + JSON.stringify(Array.from(detection.descriptor))); } btnCapture.onclick = captureAndDetect; window.addEventListener('load', init); </script> </body> </html>
Installation
composer require banguncode/php-frista
Requirements
- PHP >= 5.5
- Extensions:
curl,fileinfo - Outbound HTTPS access to
frista.bpjs-kesehatan.go.id
Directory Structure
php-frista/
├── src/
│ ├── FacialRecognition.php
│ └── StatusCode.php
├── tests/
│ └── FacialRecognitionTest.php
├── composer.json
└── README.md
Usage
1. Authenticate
<?php require __DIR__ . '/vendor/autoload.php'; use PHPFrista\FacialRecognition; use PHPFrista\StatusCode; $frista = (new FacialRecognition())->init('vclaim_username', 'vclaim_password');
The token is cached in the PHP session. On each call, it is validated against the server via checkSession() before being reused. If the server reports the token as expired or invalid, a fresh login is performed automatically — no manual token management needed.
Default values:
- Base URL:
https://frista.bpjs-kesehatan.go.id - API version:
3.0.2
To override either:
$frista = (new FacialRecognition()) ->setBaseUrl('https://staging.example.com') ->setVersion('3.0.3') ->init('username', 'password');
2. Validate Token (optional)
checkSession() pings the server to confirm the current token is still active.
It is called automatically inside auth(), but you can also call it directly.
$valid = $frista->checkSession(); // true, false, or null (no token)
3. Identify by Face (no ID needed)
recognition() searches the entire FRISTA database using a face encoding alone.
Useful when the participant's ID is unknown — e.g. a walk-in patient.
$result = $frista->recognition($encoding); // raw decoded response or null
4. Look Up by NIK
nik() retrieves a participant's biometric record by their 16-digit NIK.
$result = $frista->nik('3212************'); // raw decoded response or null
5. Verify a Face
$id = '3212************'; // 16-digit NIK or 13-digit BPJS card number $encoding = $request->getJSON()->encoding; // 128 floats forwarded from face-api.js $result = $frista->verify($id, $encoding); switch ($result['status']) { case StatusCode::OK: // $result['data'] contains: match_id, nama, nik, nokartu, score echo 'Verified: ' . $result['data']['nama']; break; case StatusCode::ALREADY_REGISTERED: echo 'Already checked in today.'; break; case StatusCode::UNREGISTERED: // Participant is in BPJS but has no biometric yet. // Register via image file/base64: $frista->register($id, $file) // Register via encoding directly: $frista->uploadEncoding($id, $encoding) break; case StatusCode::FACE_MISMATCH: // $result['data']['distance'] is available for debugging echo 'Face did not match: ' . $result['message']; break; case StatusCode::FACE_UNPROCESSABLE: echo 'Photo quality too low, ask participant to retake.'; break; case StatusCode::DUPLICATE_PHOTO: echo 'Identical photo was used before — participant must take a new photo.'; break; default: echo 'Error: ' . $result['message']; break; }
6. Register a Face (first-time only)
Only call register() when verify() returns StatusCode::UNREGISTERED.
From a JPEG file:
$upload = $frista->register($id, '/path/to/photo.jpg', true);
From a base64 string (data URI prefix is handled automatically):
$b64 = base64_encode(file_get_contents('/path/to/photo.jpg')); // or a data URI: 'data:image/jpeg;base64,...' $upload = $frista->register($id, $b64, false);
Both forms return:
['status' => StatusCode::OK, 'message' => 'Pendaftaran biometrik berhasil'] // or ['status' => StatusCode::INTEGRATION_ERROR, 'message' => '...']
Register via encoding (when a JPEG is not available but the face descriptor is already known):
// kanal refers to the type of healthcare facility: // 'fktl' — Fasilitas Kesehatan Tingkat Lanjutan (hospital/specialist) // 'fktp' — Fasilitas Kesehatan Tingkat Pertama (clinic/puskesmas) $result = $frista->uploadEncoding($id, $encoding); // defaults to 'fktl' $result = $frista->uploadEncoding($id, $encoding, 'fktp'); // primary care facility // returns raw decoded response or null
API Response Reference — /face/match2
These are the raw server responses from the match2 endpoint and their corresponding StatusCode mappings.
| HTTP | Server status |
StatusCode constant | Notes |
|---|---|---|---|
| 400 | — | INTEGRATION_ERROR |
Malformed payload |
| 200 | false |
INVALID_ID |
"ID harus numeric" — caught locally before the request |
| 200 | false |
FACE_UNPROCESSABLE |
"Wajah tidak dapat diproses" — encoding sent but face extraction failed |
| 200 | false |
FACE_MISMATCH |
"Verifikasi wajah gagal. Foto tidak sesuai" — data.distance available |
| 200 | false |
UNREGISTERED |
code: "0" — participant exists but has no biometric on record |
| 200 | false |
ALREADY_REGISTERED |
"Peserta telah terdaftar hari ini" — treated as a pass |
| 200 | false |
DUPLICATE_PHOTO |
"Anda pernah melakukan upload dengan foto yang identik" |
| 200 | true |
OK |
data contains match_id, nama, nik, nokartu, score |
Example success response from the server:
{
"data": {
"match_id": "69fc********************",
"nama": "RUM HAIDAR FAUZAN",
"nik": "3212************",
"nokartu": "000*******245",
"score": 0.604043983216737
},
"message": "Data ditemukan",
"status": true
}
Example mismatch response (StatusCode::FACE_MISMATCH):
{
"data": { "distance": 99.30039597991038 },
"message": "Verifikasi wajah gagal. Foto tidak sesuai",
"status": false
}
Example unregistered response (StatusCode::UNREGISTERED):
{
"code": "0",
"message": "Peserta belum melakukan perekaman wajah. Harap Hubungi Admin.",
"status": false
}
Status Code Reference
| Constant | Description |
|---|---|
OK |
Operation successful |
ALREADY_REGISTERED |
Participant already verified today |
UNREGISTERED |
No biometric on record — enrol with register() |
FACE_MISMATCH |
Face recognised but score below threshold |
FACE_UNPROCESSABLE |
Server could not extract a face from the encoding |
DUPLICATE_PHOTO |
Identical photo was previously uploaded |
AUTH_FAILED |
Login to BPJS server failed |
INVALID_ID |
Identity number is not 13 or 16 digits |
INVALID_ENCODING |
Encoding is not 128 numeric values |
INVALID_IMAGE |
Image is not a valid JPEG or path does not exist |
INTEGRATION_ERROR |
Unexpected error from the BPJS server |
INTERNAL_SERVER_ERROR |
Server response was empty or unreadable |
SERVER_UNREACHABLE |
Connection timed out or server is down |