banguncode/php-frista

A simple PHP library for BPJS Kesehatan facial recognition integration.

Maintainers

Package info

github.com/banguncode/PHP-Frista

pkg:composer/banguncode/php-frista

Statistics

Installs: 27

Dependents: 0

Suggesters: 0

Stars: 7

Open Issues: 0

v1.1.0 2026-05-26 02:25 UTC

This package is auto-updated.

Last update: 2026-05-26 03:36:13 UTC


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 to StatusCode constants.
  • 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