openapi: 3.0.3 info: title: Biometric Attendance API description: | Multi-tenant biometric attendance API. Each client (school/company) has its own API key, users, and devices. This spec is split into two areas: ## Device API ESP32 terminal endpoints under `/api/*`. Authenticate with the **`X-API-KEY`** header (per-client device key). Device endpoints are **not rate-limited**. Throughput is bounded by your server, database, and network. ## Current firmware path (recommended) Production terminals (v43+) match fingerprints **on the R307 sensor**, then call the API with **`finger_slot`**: ``` ENROLL: lookup → allocate_slot → storeModel → POST /enroll/backup (R307 char file) CLOCK: fingerSearch → POST /clock { device_id, finger_slot } → toggles in ↔ out RESTORE: Admin → POST /enroll/restore (paged) → DownChar + storeModel on new/wiped device ``` `POST /clock` is the preferred attendance endpoint. `POST /clock-in` and `POST /clock-out` remain for forced type or template-based matching (tests / legacy). **Template backup** (`POST /enroll/backup`) stores the on-device R307 template in `biometric_templates` so a replacement terminal can rebuild its sensor library without re-scanning fingers. ## Attendance rules (portal settings) Clock-in may be **allowed**, **marked late**, or **denied** based on client settings and the user's session: | Reason | Typical title | Meaning | |--------|---------------|---------| | `clock_in_window` | TOO EARLY | Before the configured early window | | `session_closed` | NO SESSION TODAY | Session does not run on this weekday | | `session_ended` | SESSION ENDED | After session end time (clock-in blocked; clock-out still allowed) | | `inactive` | NOT ALLOWED | User deactivated | Rule denials return **HTTP 200** with `ok: false`, `reason`, `title`, and `message` so ESP32 HTTPClient can read the body reliably. Successful clock-in may include `"late": true` when past the late threshold (still recorded). Optional **auto clock-out** and **employee labor cost** are portal/cron features (not device API endpoints). ## Data storage model | What | Where | When | |------|-------|------| | Device finger slot mapping | **`biometric_mappings`** | `POST /enroll` (on-device R307 path) | | Optional server templates | **`biometric_templates`** (encrypted) | Legacy / template enroll | | Clock-in / clock-out events | **`attendance_logs`** (`is_late`, `is_auto`) | `POST /clock`, `/clock-in`, `/clock-out` | ## Client isolation (multi-tenant security) Each device is provisioned for **one client only**. A user enrolled under Client A **cannot** clock in on Client B's device: | Layer | Enforcement | |-------|-------------| | `X-API-KEY` | Resolves the client; wrong key → `401` | | `device_id` | Must belong to that client in `devices` → else `422` | | Slot / template match | Scoped to **that client only** | | Users | Scoped by `client_id`; enrollment codes unique per client | **Device provisioning (not in this spec)** Terminals are registered by a platform admin before use — there is no self-registration API. See the platform web portal: `GET /platform/clients/create` or approve a pending client with a `device_id`. ## Admin API JSON REST endpoints under `/api/admin/*` for client admin mobile/integrations. Authenticate with **`Authorization: Bearer {token}`** after `POST /admin/login`. The browser web portal (`/admin/*`, `/platform/*`) is HTML-based and is not documented here (sessions, settings, attendance reports, labor cost). version: 3.3.0 servers: - url: http://localhost:8001/api description: Local development (Docker artisan serve on port 8001) - url: /api description: Relative — same host that serves this OpenAPI file x-tagGroups: - name: Device API tags: - Device — Attendance - Device — Registration - name: Admin API tags: - Admin — Authentication - Admin — Users - Admin — Imports paths: # ── Device API ────────────────────────────────────────────────────────────── /clock: post: summary: Toggle clock-in / clock-out (preferred) description: | **Preferred for ESP32 firmware.** Resolves the user via `finger_slot` (on-device R307 match) or `template` (server matcher), then toggles attendance: - last event was `out` or none → record `in` - last event was `in` → record `out` **Database:** Inserts into `attendance_logs` (`is_late` when clock-in is past the late threshold). Clock-in may be denied by portal attendance rules. Those denials return **HTTP 200** with `ok: false` (see `AttendanceRuleDenied`) so device HTTP clients can read the body. operationId: clockToggle tags: - Device — Attendance security: - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AttendanceRequest' examples: fingerSlot: summary: Recommended — on-device R307 match value: device_id: TERM-01 finger_slot: 7 template: summary: Legacy / tests — server template match value: device_id: TERM-01 template: "YWR5YW4tZmluZ2VycHJpbnQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" responses: '200': description: | Either attendance recorded (`ok: true`) or a rule denial (`ok: false`). Always check the `ok` field — do not assume success from HTTP status alone. content: application/json: schema: oneOf: - $ref: '#/components/schemas/AttendanceSuccess' - $ref: '#/components/schemas/AttendanceRuleDenied' examples: successIn: summary: Clock-in recorded value: ok: true type: in late: false name: Adyan Khan user_id: 5 finger_slot: 7 matched_via: finger_slot time: "09:02 AM" recorded_at: "2026-07-19T08:02:00+00:00" timezone: Europe/London lateIn: summary: Clock-in recorded as late value: ok: true type: in late: true name: Bilal Late user_id: 6 finger_slot: 3 matched_via: finger_slot time: "09:18 AM" recorded_at: "2026-07-19T08:18:00+00:00" timezone: Europe/London tooEarly: summary: Rule denial — too early value: ok: false reason: clock_in_window title: TOO EARLY message: Clock-in opens at 08:45. window_opens_at: "08:45" sessionEnded: summary: Rule denial — session ended value: ok: false reason: session_ended title: SESSION ENDED message: This session has ended for today. '401': $ref: '#/components/responses/UnauthorizedDevice' '404': description: No mapping for finger_slot, or no template match content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' examples: unknownSlot: value: ok: false message: No registration record maps to this slot configuration. noTemplateMatch: value: ok: false message: No enrolled fingerprint matches this scan. '422': $ref: '#/components/responses/ValidationError' /clock-in: post: summary: Force a clock-in event description: | Same matching as `POST /clock`, but always records `type: in` (no toggle). Prefer `POST /clock` on terminals. Use this for tests or forced clock-in. operationId: clockIn tags: - Device — Attendance security: - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AttendanceRequest' examples: fingerSlot: value: device_id: TERM-01 finger_slot: 7 template: value: device_id: TERM-01 template: "YWR5YW4tZmluZ2VycHJpbnQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" responses: '200': description: "Attendance recorded (ok true) or rule denial (ok false)" content: application/json: schema: oneOf: - $ref: '#/components/schemas/AttendanceSuccess' - $ref: '#/components/schemas/AttendanceRuleDenied' '401': $ref: '#/components/responses/UnauthorizedDevice' '404': description: No mapping for finger_slot, or no template match content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: ok: false message: No registration record maps to this slot configuration. '422': $ref: '#/components/responses/ValidationError' /clock-out: post: summary: Force a clock-out event description: | Same matching as `POST /clock`, but always records `type: out` (no toggle). Clock-out is still allowed after session end; weekday/session-closed rules may still deny. operationId: clockOut tags: - Device — Attendance security: - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AttendanceRequest' examples: fingerSlot: value: device_id: TERM-01 finger_slot: 7 responses: '200': description: "Attendance recorded (ok true) or rule denial (ok false)" content: application/json: schema: oneOf: - $ref: '#/components/schemas/AttendanceSuccess' - $ref: '#/components/schemas/AttendanceRuleDenied' '401': $ref: '#/components/responses/UnauthorizedDevice' '404': description: No mapping for finger_slot, or no template match content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '422': $ref: '#/components/responses/ValidationError' /users: post: summary: Register a new user and enroll fingerprint slot (legacy) description: | **Legacy endpoint.** Prefer portal user creation + `POST /enroll`. **Database:** Creates a row in `users`, then **inserts** the encrypted `template` into `biometric_templates`. **Device:** Sends captured fingerprint as base64 `template` — not stored locally on device. operationId: registerUser tags: - Device — Registration security: - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RegisterUserRequest' example: full_name: Adyan Khan device_id: TERM-01 template: "YWR5YW4tZmluZ2VycHJpbnQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" responses: '201': description: User created and `biometric_templates` row inserted content: application/json: schema: $ref: '#/components/schemas/UserCreatedResponse' '401': $ref: '#/components/responses/UnauthorizedDevice' '422': description: Slot already assigned or validation failed content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /enroll/lookup: post: summary: Look up a portal user by 4-digit enrollment code before fingerprint capture description: | Call when a person enters their enrollment code on the device keypad. Returns session info, whether a `finger_slot` is already paired on this device, and legacy template enrollment status (`enrolled_positions`, `enrollment_complete`). operationId: enrollLookup tags: - Device — Registration security: - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EnrollLookupRequest' example: enrollment_code: "4827" device_id: TERM-01 responses: '200': description: User found and active content: application/json: schema: $ref: '#/components/schemas/EnrollLookupResponse' '401': $ref: '#/components/responses/UnauthorizedDevice' '403': description: User exists but is inactive content: application/json: schema: $ref: '#/components/schemas/EnrollLookupResponse' example: ok: false inactive: true status: inactive name: Cara Absent enrollment_code: "1234" '404': description: No user matches this enrollment code content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: ok: false message: No user matches this enrollment code. '422': $ref: '#/components/responses/ValidationError' /enroll: post: summary: Enroll or pair a portal user on a device description: | Supports three modes: 1. **On-device R307 (recommended firmware):** `{ allocate_slot: true }` — allocates a `biometric_mappings.finger_slot`. Device then runs `storeModel(slot)` locally. No server template matching is required for daily attendance. 2. **Server templates (legacy / tests):** batched `templates: { center, left, right }` or single `position` + `template`. Also creates a finger_slot mapping when possible. 3. **Pair only:** `{ pair_device: true }` when server templates are already complete. **Batch validation:** Rejects the entire request if any scan matches a different position in the same payload. Returns **`422`** with a `hint` for the LCD. operationId: enrollExistingUser tags: - Device — Registration security: - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EnrollRequest' examples: allocateSlot: summary: Recommended — allocate R307 finger_slot value: enrollment_code: "4827" device_id: TERM-01 allocate_slot: true sensor_type: r307 batchedDeviceEnroll: summary: Legacy — ESP32 template batch (3 scans, 1 request) value: enrollment_code: "4827" device_id: TERM-01 sensor_type: r307 templates: center: "YWR5YW4tZmluZ2VycHJpbnQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" left: "bGVmdC1maW5nZXJwcmludAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" right: "cmlnaHQtZmluZ2VycHJpbnQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" byEnrollmentCode: summary: Legacy — one position per request value: enrollment_code: "4827" device_id: TERM-01 position: center template: "YWR5YW4tZmluZ2VycHJpbnQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" byExternalId: summary: Legacy — roll number or employee ID value: external_id: "1001" device_id: TERM-01 position: left template: "YWR5YW4tZmluZ2VycHJpbnQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" responses: '201': description: Slot allocated and/or templates stored content: application/json: schema: $ref: '#/components/schemas/EnrollSuccessResponse' '200': description: Slot already enrolled on this device (`allocate_slot` path) content: application/json: schema: $ref: '#/components/schemas/EnrollSuccessResponse' '401': $ref: '#/components/responses/UnauthorizedDevice' '403': description: User is inactive content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '422': description: Position already enrolled, wrong finger placement, or validation failed content: application/json: schema: oneOf: - $ref: '#/components/schemas/EnrollmentPlacementError' - $ref: '#/components/schemas/ErrorResponse' examples: wrongPlacement: summary: Scan matches a different enrolled position value: ok: false message: This scan matches your already enrolled center (flat on sensor) placement. Please tilt your finger slightly to the left and scan again. requested_position: left matched_position: center hint: Please tilt your finger slightly to the left positionTaken: summary: Position already stored (legacy single enroll) value: ok: false message: This finger position is already enrolled for this user. alreadyEnrolled: summary: Batch rejected — user already has templates value: ok: false message: User already has enrolled fingerprint data. Clear enrollment in the portal before enrolling again. /enroll/unmap: post: summary: Release a device finger_slot mapping description: | Removes the `biometric_mappings` row for this user on `device_id`. Optionally deactivates the user when `deactivate: true`. Firmware should also clear the R307 slot locally after a successful release. operationId: unmapDevice tags: - Device — Registration security: - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EnrollUnmapRequest' example: enrollment_code: "4827" device_id: TERM-01 deactivate: false responses: '200': description: Mapping released (or already absent) content: application/json: schema: $ref: '#/components/schemas/EnrollUnmapResponse' '401': $ref: '#/components/responses/UnauthorizedDevice' '422': $ref: '#/components/responses/ValidationError' /enroll/backup: post: summary: Backup R307 template after storeModel description: | Called by firmware immediately after a successful on-device enroll. Upserts `biometric_templates.position = center` for disaster recovery. Requires an existing `biometric_mappings` row for `(device_id, finger_slot)`. operationId: backupSensorTemplate tags: - Device — Registration security: - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: type: object required: [device_id, finger_slot, template] properties: enrollment_code: type: string minLength: 4 maxLength: 4 external_id: oneOf: [{type: integer}, {type: string}] device_id: type: string example: TERM-01 finger_slot: type: integer example: 7 template: type: string description: Base64-encoded 512-byte R307 char file from loadModel/getModel sensor_type: type: string example: r307 responses: '200': description: Template stored content: application/json: schema: type: object properties: ok: { type: boolean, example: true } backed_up: { type: boolean, example: true } has_backup: { type: boolean, example: true } user_id: { type: integer } finger_slot: { type: integer } position: { type: string, example: center } '401': $ref: '#/components/responses/UnauthorizedDevice' '422': $ref: '#/components/responses/ValidationError' /enroll/restore: post: summary: Paginated restore payloads for a device description: | Returns active users that have a center template backup, allocating a `finger_slot` on `device_id` when missing. Firmware writes each template with DownChar + storeModel, then requests the next page via `after_user_id`. operationId: restoreSensorTemplates tags: - Device — Registration security: - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: type: object required: [device_id] properties: device_id: type: string example: TERM-02 limit: type: integer minimum: 1 maximum: 10 default: 5 after_user_id: type: integer description: Resume after this user_id (from previous next_after_user_id) default: 0 example: device_id: TERM-02 limit: 3 after_user_id: 0 responses: '200': description: Page of restore items content: application/json: schema: type: object properties: ok: { type: boolean } device_id: { type: string } items: type: array items: type: object properties: user_id: { type: integer } name: { type: string } enrollment_code: { type: string } finger_slot: { type: integer } template: { type: string } sensor_type: { type: string } next_after_user_id: type: integer nullable: true remaining: { type: integer } done: { type: boolean } '401': $ref: '#/components/responses/UnauthorizedDevice' '422': $ref: '#/components/responses/ValidationError' # ── Admin API ─────────────────────────────────────────────────────────────── /admin/login: post: summary: Client admin login operationId: adminLogin tags: - Admin — Authentication security: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AdminLoginRequest' responses: '200': description: Bearer token issued content: application/json: schema: $ref: '#/components/schemas/AdminLoginResponse' '422': $ref: '#/components/responses/ValidationError' /admin/logout: post: summary: Revoke the current admin API token operationId: adminLogout tags: - Admin — Authentication security: - BearerAuth: [] responses: '200': description: Token revoked content: application/json: schema: type: object properties: ok: type: boolean example: true '401': $ref: '#/components/responses/UnauthorizedAdmin' /admin/me: get: summary: Get the authenticated client admin profile operationId: adminMe tags: - Admin — Authentication security: - BearerAuth: [] responses: '200': description: Admin profile content: application/json: schema: $ref: '#/components/schemas/AdminProfile' '401': $ref: '#/components/responses/UnauthorizedAdmin' /admin/users: get: summary: List users for the authenticated client operationId: listUsers tags: - Admin — Users security: - BearerAuth: [] parameters: - name: type in: query schema: type: string enum: [student, employee] - name: search in: query description: Filter by full name or external ID schema: type: string - name: per_page in: query schema: type: integer default: 50 responses: '200': description: Paginated user list content: application/json: schema: $ref: '#/components/schemas/PaginatedUsers' '401': $ref: '#/components/responses/UnauthorizedAdmin' /admin/imports: post: summary: Import students or employees from CSV or Excel description: | Upload a `.csv` or `.xlsx` file with columns `external_id`, `full_name`, and `type`. Excel imports use the first worksheet only. operationId: importUsers tags: - Admin — Imports security: - BearerAuth: [] requestBody: required: true content: multipart/form-data: schema: type: object required: [class_session_id, file] properties: class_session_id: type: integer description: Session/class/event to assign all imported users to file: type: string format: binary description: CSV or XLSX file (max 5 MB) responses: '202': description: Import queued content: application/json: schema: $ref: '#/components/schemas/ImportQueuedResponse' '401': $ref: '#/components/responses/UnauthorizedAdmin' '422': $ref: '#/components/responses/ValidationError' /admin/imports/{importBatchId}: get: summary: Get import batch status operationId: getImportStatus tags: - Admin — Imports security: - BearerAuth: [] parameters: - name: importBatchId in: path required: true schema: type: integer responses: '200': description: Import status content: application/json: schema: $ref: '#/components/schemas/ImportBatchStatus' '401': $ref: '#/components/responses/UnauthorizedAdmin' '404': description: Import batch not found components: securitySchemes: ApiKeyAuth: type: apiKey in: header name: X-API-KEY description: Per-client device API key (Device API only) BearerAuth: type: http scheme: bearer description: Sanctum token from POST /admin/login (Admin API only) schemas: FingerPosition: type: string enum: [center, left, right] description: Which part of the finger was captured during enrollment AttendanceRequest: type: object required: - device_id properties: device_id: type: string example: TERM-01 finger_slot: type: integer minimum: 1 description: > R307 flash ID from fingerFastSearch (preferred for devices). Required unless template is sent. example: 7 template: type: string description: > Base64 fingerprint template (fallback / tests). Required unless finger_slot is sent. example: "YWR5YW4tZmluZ2VycHJpbnQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" RegisterUserRequest: type: object required: - full_name - device_id - position - template properties: full_name: type: string example: Adyan Khan device_id: type: string example: TERM-01 position: $ref: '#/components/schemas/FingerPosition' template: type: string description: Base64-encoded fingerprint template sensor_type: type: string example: generic description: Optional sensor identifier (e.g. r307, r551) EnrollLookupRequest: type: object required: - enrollment_code - device_id properties: enrollment_code: type: string minLength: 4 maxLength: 4 pattern: '^[0-9]{4}$' description: 4-digit code shown in the client admin portal example: "4827" device_id: type: string example: TERM-01 EnrollLookupResponse: type: object properties: ok: type: boolean example: true user_id: type: integer example: 42 name: type: string example: Adyan Khan enrollment_code: type: string example: "4827" external_id: type: string nullable: true example: "1001" type: type: string enum: [student, employee] example: student session: type: string nullable: true description: Assigned class/session name from the portal example: Morning Batch already_enrolled: type: boolean description: True when this user already has a finger_slot mapping on this device example: false device_store_needed: type: boolean description: True when server templates are complete but this device still needs storeModel pairing example: false finger_slot: type: integer nullable: true description: Existing R307 flash ID on this device, if already paired example: 7 inactive: type: boolean description: True when the user is deactivated example: false status: type: string example: active enrolled_positions: type: array items: $ref: '#/components/schemas/FingerPosition' example: [center] next_position: allOf: - $ref: '#/components/schemas/FingerPosition' nullable: true example: left enrollment_complete: type: boolean example: false EnrollRequest: oneOf: - $ref: '#/components/schemas/EnrollAllocateSlotRequest' - $ref: '#/components/schemas/EnrollPairDeviceRequest' - $ref: '#/components/schemas/EnrollBatchRequest' - $ref: '#/components/schemas/EnrollSingleRequest' EnrollAllocateSlotRequest: type: object description: On-device R307 path — allocate finger_slot only required: - device_id - allocate_slot properties: enrollment_code: type: string minLength: 4 maxLength: 4 pattern: '^[0-9]{4}$' example: "4827" external_id: oneOf: - type: integer - type: string example: "1001" device_id: type: string example: TERM-01 allocate_slot: type: boolean enum: [true] sensor_type: type: string example: r307 oneOf: - required: [enrollment_code, device_id, allocate_slot] - required: [external_id, device_id, allocate_slot] EnrollPairDeviceRequest: type: object description: Pair a device when server templates are already complete required: - device_id - pair_device properties: enrollment_code: type: string minLength: 4 maxLength: 4 pattern: '^[0-9]{4}$' external_id: oneOf: - type: integer - type: string device_id: type: string example: TERM-01 pair_device: type: boolean enum: [true] oneOf: - required: [enrollment_code, device_id, pair_device] - required: [external_id, device_id, pair_device] EnrollUnmapRequest: type: object required: - device_id properties: enrollment_code: type: string minLength: 4 maxLength: 4 pattern: '^[0-9]{4}$' example: "4827" external_id: oneOf: - type: integer - type: string device_id: type: string example: TERM-01 deactivate: type: boolean default: false description: When true, also marks the user inactive oneOf: - required: [enrollment_code, device_id] - required: [external_id, device_id] EnrollUnmapResponse: type: object properties: ok: type: boolean example: true released: type: boolean description: True when a mapping row was deleted user_id: type: integer name: type: string finger_slot: type: integer nullable: true description: Slot that was released (for local R307 delete) deactivated: type: boolean status: type: string example: active EnrollBatchRequest: type: object required: - device_id - templates properties: enrollment_code: type: string minLength: 4 maxLength: 4 pattern: '^[0-9]{4}$' description: Recommended — 4-digit portal enrollment code example: "4827" external_id: oneOf: - type: integer - type: string description: Legacy — roll number, employee ID, or internal users.id example: "1001" device_id: type: string example: TERM-01 sensor_type: type: string example: r307 templates: type: object required: - center - left - right properties: center: type: string description: Base64 char file from center (flat) scan left: type: string description: Base64 char file from left-tilt scan right: type: string description: Base64 char file from right-tilt scan oneOf: - required: [enrollment_code, device_id, templates] - required: [external_id, device_id, templates] EnrollSingleRequest: type: object required: - device_id - position - template properties: enrollment_code: type: string minLength: 4 maxLength: 4 pattern: '^[0-9]{4}$' description: Recommended — 4-digit portal enrollment code example: "4827" external_id: oneOf: - type: integer - type: string description: Legacy — roll number, employee ID, or internal users.id example: "1001" device_id: type: string example: TERM-01 position: $ref: '#/components/schemas/FingerPosition' template: type: string description: Base64-encoded fingerprint template from device sensor sensor_type: type: string example: generic oneOf: - required: [enrollment_code, device_id, position, template] - required: [external_id, device_id, position, template] AttendanceSuccess: type: object properties: ok: type: boolean example: true type: type: string enum: [in, out] description: Resolved attendance type (toggle or forced) late: type: boolean description: True when clock-in was past the late threshold (still recorded) example: false name: type: string example: Adyan Khan user_id: type: integer example: 5 finger_slot: type: integer description: Present when matched via on-device slot example: 7 matched_position: allOf: - $ref: '#/components/schemas/FingerPosition' description: Present when matched via server template matched_via: type: string enum: [finger_slot, template] time: type: string description: Display clock time in `timezone` example: "10:42 AM" recorded_at: type: string format: date-time example: "2026-07-19T09:42:00+00:00" timezone: type: string example: Europe/London AttendanceRuleDenied: type: object description: | Attendance rule denial. Returned with **HTTP 200** and `ok: false` so ESP32 HTTPClient can reliably read the body. required: - ok - reason - title - message properties: ok: type: boolean enum: [false] reason: type: string enum: [clock_in_window, session_closed, session_ended, inactive] title: type: string description: Short LCD title example: TOO EARLY message: type: string example: Clock-in opens at 08:45. window_opens_at: type: string nullable: true description: Present for `clock_in_window` denials (HH:MM local) example: "08:45" inactive: type: boolean description: Present for `inactive` denials EnrollSuccessResponse: type: object properties: ok: type: boolean example: true user_id: type: integer example: 5 name: type: string example: Adyan Khan enrollment_code: type: string example: "4827" position: $ref: '#/components/schemas/FingerPosition' enrolled_positions: type: array items: $ref: '#/components/schemas/FingerPosition' next_position: allOf: - $ref: '#/components/schemas/FingerPosition' nullable: true enrollment_complete: type: boolean example: false finger_slot: type: integer description: R307 flash ID allocated for this device — firmware must storeModel(this id) example: 7 paired: type: boolean description: True when only a device slot was allocated (templates already existed) example: false UserCreatedResponse: type: object properties: ok: type: boolean example: true user_id: type: integer example: 5 name: type: string example: Adyan Khan enrollment_code: type: string example: "4827" AdminLoginRequest: type: object required: [email, password] properties: email: type: string format: email example: admin@demo-school.test password: type: string format: password example: password AdminLoginResponse: type: object properties: token: type: string description: Bearer token for Admin API requests admin: $ref: '#/components/schemas/AdminProfile' AdminProfile: type: object properties: id: type: integer example: 1 name: type: string example: Demo Admin email: type: string format: email example: admin@demo-school.test client_id: type: integer example: 1 PaginatedUsers: type: object description: Laravel paginator JSON shape properties: data: type: array items: type: object properties: id: type: integer full_name: type: string external_id: type: string nullable: true type: type: string enum: [student, employee] enrollment_code: type: string nullable: true current_page: type: integer last_page: type: integer per_page: type: integer total: type: integer ImportQueuedResponse: type: object properties: ok: type: boolean example: true import_batch_id: type: integer example: 12 status: type: string example: pending ImportBatchStatus: type: object properties: id: type: integer filename: type: string status: type: string enum: [pending, processing, completed, failed] total_rows: type: integer nullable: true created_count: type: integer updated_count: type: integer failed_count: type: integer errors: type: array items: type: object ErrorResponse: type: object properties: ok: type: boolean example: false message: type: string EnrollmentPlacementError: type: object description: Scan matched a different finger placement than requested during enrollment properties: ok: type: boolean example: false message: type: string example: This scan matches your already enrolled center (flat on sensor) placement. Please tilt your finger slightly to the left and scan again. requested_position: $ref: '#/components/schemas/FingerPosition' matched_position: $ref: '#/components/schemas/FingerPosition' hint: type: string description: Short instruction for the device LCD example: Please tilt your finger slightly to the left required: - ok - message - requested_position - matched_position - hint BiometricTemplate: type: object description: | Encrypted fingerprint template (table **`biometric_templates`**). Up to **3 rows per user**: `center`, `left`, `right`. **Created by:** `POST /enroll` (template modes), `POST /users` **Matched by:** `POST /clock`, `/clock-in`, `/clock-out` when `template` is sent (legacy / tests). Production terminals prefer `finger_slot` via `biometric_mappings`. properties: client_id: type: integer example: 1 user_id: type: integer example: 42 position: $ref: '#/components/schemas/FingerPosition' template: type: string description: Base64 template (encrypted at rest — not returned by API) sensor_type: type: string example: generic enrolled_device_id: type: string nullable: true description: Device used during enrollment (informational) example: TERM-01 BiometricMapping: type: object description: | On-device R307 slot pairing (table **`biometric_mappings`**). Maps `(client_id, device_id, finger_slot) → user`. **Created by:** `POST /enroll` with `allocate_slot` / template enroll / `pair_device` **Resolved by:** `POST /clock` with `finger_slot` properties: client_id: type: integer device_id: type: string example: TERM-01 user_id: type: integer finger_slot: type: integer example: 7 AttendanceLog: type: object description: | One clock-in or clock-out event (table **`attendance_logs`**). **Created by:** `POST /clock`, `/clock-in`, `/clock-out`, or the `attendance:auto-clock-out` scheduler (`is_auto: true`). properties: client_id: type: integer device_id: type: string description: Device that recorded the event (copied from last in for auto outs) user_id: type: integer type: type: string enum: [in, out] is_late: type: boolean description: Set on clock-in when past the late threshold is_auto: type: boolean description: True when created by auto clock-out at session end recorded_at: type: string format: date-time responses: UnauthorizedDevice: description: Missing or invalid X-API-KEY content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' example: ok: false message: Unauthorized hardware peripheral. UnauthorizedAdmin: description: Missing or invalid Bearer token content: application/json: schema: type: object properties: message: type: string example: Unauthenticated. ValidationError: description: Invalid request payload content: application/json: schema: type: object properties: message: type: string errors: type: object