> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fitsociety.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Access Devices

> Validate QR codes and partner credentials at access gates with device-key authentication.

Access device endpoints live under `/public/v1/access`, but they are not
developer Public API resources. They keep the existing gate/device validation
contract and use device-key authentication instead of OAuth client credentials.

Use these endpoints for turnstiles, door controllers, badge scanners, and other
edge devices that need an immediate allow/deny decision.

<Warning>
  Do not use Public API OAuth client credentials or `Authorization: Bearer
      fspt_...` access tokens for access devices. Use `x-device-key` for
  `/public/v1/access/*`.
</Warning>

## Authentication

Every access-device request must include a device key:

```http theme={null}
x-device-key: <device_key>
```

The preferred header is `x-device-key`. Runtime also accepts the legacy aliases
`x-access-device-key` and `x-api-key` for access-device requests only. Do not use
those aliases for developer Public API resources.

Device keys must be hex strings with at least 32 characters. Missing, malformed,
inactive, or unknown device keys return `401 DEVICE_NOT_AUTHORIZED`.

The company context is resolved from the configured access device. Use the
device key for `/public/v1/access/*`; Public API OAuth tokens are for developer
API resources.

## Response envelope

Access-device endpoints use the FITsociety application envelope, not the
developer Public API `{ data, meta }` envelope:

```json theme={null}
{
  "status": 200,
  "error": false,
  "message": "ACCESS_DECISION",
  "data": {
    "allowed": true,
    "reasonCode": "OK",
    "reasonMessage": "Access granted",
    "openDurationSeconds": 5
  }
}
```

## Check device health

```http theme={null}
GET /public/v1/access/health
```

Returns a lightweight heartbeat when the device key is valid.

### Example

```bash theme={null}
curl -X GET \
  -H "x-device-key: <device_key>" \
  https://api.fitsociety.io/public/v1/access/health
```

### Success response

```json theme={null}
{
  "status": 200,
  "error": false,
  "message": "ACCESS_HEALTH_OK",
  "data": {
    "status": "ok",
    "device": {
      "id": "66f7b8b1e13c8d25f4d3d90a",
      "locationId": "66f7b8b1e13c8d25f4d3d90b",
      "zoneKey": "front_door"
    },
    "time": "2026-07-14T12:00:00.000Z"
  }
}
```

| Field               | Type   | Nullable | Description                              |
| :------------------ | :----- | :------- | :--------------------------------------- |
| `status`            | string | no       | Static heartbeat value, currently `ok`.  |
| `device.id`         | string | no       | Access device ID.                        |
| `device.locationId` | string | yes      | Device location ID when configured.      |
| `device.zoneKey`    | string | yes      | Device zone key when configured.         |
| `time`              | string | no       | Server UTC timestamp in ISO 8601 format. |

### Auth errors

| Status | Message                 |
| :----- | :---------------------- |
| `401`  | `DEVICE_NOT_AUTHORIZED` |

## Validate access

```http theme={null}
POST /public/v1/access/validate
```

Evaluates a QR token or partner credential and returns an allow/deny decision.
Send exactly one of these request shapes:

* QR validation: `qrToken`
* Partner credential validation: `credentialType`, `credentialValue`, `scanId`

If `qrToken` is present, the request is treated as QR validation.

### QR request

```bash theme={null}
curl -X POST \
  -H "x-device-key: <device_key>" \
  -H "Content-Type: application/json" \
  -d '{"qrToken":"<signed_qr_token>"}' \
  https://api.fitsociety.io/public/v1/access/validate
```

| Field     | Type   | Required | Description                                          |
| :-------- | :----- | :------- | :--------------------------------------------------- |
| `qrToken` | string | yes      | Signed QR token generated by the client access flow. |

QR retries are idempotent through the QR session ID embedded in the signed
token.

### Partner credential request

```bash theme={null}
curl -X POST \
  -H "x-device-key: <device_key>" \
  -H "Content-Type: application/json" \
  -d '{"credentialType":"badgeUid","credentialValue":"04AABBCCDD","scanId":"partner-scan-123"}' \
  https://api.fitsociety.io/public/v1/access/validate
```

| Field             | Type   | Required | Description                                                                                      |
| :---------------- | :----- | :------- | :----------------------------------------------------------------------------------------------- |
| `credentialType`  | string | yes      | Credential type. Current public credential flow supports `badgeUid`.                             |
| `credentialValue` | string | yes      | Raw credential value from the scanner. The API normalizes supported values before lookup.        |
| `scanId`          | string | yes      | Stable idempotency key for this physical scan. Reuse the same value when retrying the same scan. |

Partner credential scans are gated by the company access-control credential
method settings. If the credential method is disabled, the endpoint returns an
access decision with `allowed=false`.

### Decision response

Access decisions return HTTP `200` even when access is denied. Read
`data.allowed` and `data.reasonCode`.

```json theme={null}
{
  "status": 200,
  "error": false,
  "message": "ACCESS_DECISION",
  "data": {
    "allowed": false,
    "reasonCode": "OUTSIDE_ALLOWED_HOURS",
    "reasonMessage": "Access is outside the allowed hours"
  }
}
```

When access is granted, `openDurationSeconds` may be included:

```json theme={null}
{
  "status": 200,
  "error": false,
  "message": "ACCESS_DECISION",
  "data": {
    "allowed": true,
    "reasonCode": "OK",
    "reasonMessage": "Access granted",
    "openDurationSeconds": 5
  }
}
```

### Decision fields

| Field                 | Type    | Nullable | Description                                                                                 |
| :-------------------- | :------ | :------- | :------------------------------------------------------------------------------------------ |
| `allowed`             | boolean | no       | Whether the device should open access.                                                      |
| `reasonCode`          | string  | no       | Stable decision code. Treat unknown values as denied.                                       |
| `reasonMessage`       | string  | no       | Human-readable reason. Do not parse for business logic.                                     |
| `openDurationSeconds` | number  | yes      | Suggested opening duration when access is allowed and the device has a duration configured. |

### Decision codes

| Reason code                   | Meaning                                                        |
| :---------------------------- | :------------------------------------------------------------- |
| `OK`                          | Access is allowed.                                             |
| `QR_INVALID`                  | QR token is missing, malformed, or fails signature validation. |
| `QR_EXPIRED`                  | QR token has expired.                                          |
| `RATE_LIMITED`                | Device exceeded the access validation rate limit.              |
| `CLIENT_NOT_FOUND`            | Client could not be resolved.                                  |
| `CLIENT_NOT_IN_COMPANY`       | Client does not belong to the device company.                  |
| `NO_ACTIVE_PRODUCT`           | Client has no active access-entitling product.                 |
| `PRODUCT_ACCESS_DISABLED`     | Product exists but access entitlement is disabled.             |
| `LOCATION_NOT_ALLOWED`        | Client/product is not allowed for the device location.         |
| `ZONE_NOT_ALLOWED`            | Client/product is not allowed for the device zone.             |
| `PAYMENT_BLOCKED`             | Access is blocked because of payment state.                    |
| `OUTSIDE_ALLOWED_HOURS`       | Scan is outside configured access hours.                       |
| `CREDITS_REQUIRED_NO_BALANCE` | Credits are required but no usable balance exists.             |
| `LIMIT_REACHED_DAY`           | Day access limit reached.                                      |
| `LIMIT_REACHED_WEEK`          | Week access limit reached.                                     |
| `LIMIT_REACHED_MONTH`         | Month access limit reached.                                    |
| `LESSON_REQUIRED_NO_BOOKING`  | A lesson booking is required but not found.                    |
| `LESSON_TOO_EARLY`            | Scan is before the allowed lesson access window.               |
| `LESSON_TOO_LATE`             | Scan is after the allowed lesson access window.                |
| `LESSON_NOT_IN_WHITELIST`     | Booking exists but is not for an allowed lesson.               |
| `CREDENTIAL_METHOD_DISABLED`  | Partner credential method is disabled for the company.         |
| `CREDENTIAL_NOT_FOUND`        | Partner credential was not found.                              |
| `CREDENTIAL_INACTIVE`         | Partner credential exists but is inactive.                     |
| `INTERNAL_ERROR`              | Access could not be resolved safely. Treat as denied.          |

### Validation errors

Malformed credential requests return client errors:

| Status | Message                     | Cause                                                              |
| :----- | :-------------------------- | :----------------------------------------------------------------- |
| `400`  | `INVALID_CREDENTIAL_TYPE`   | `credentialType` is unsupported.                                   |
| `400`  | `CREDENTIAL_VALUE_REQUIRED` | `credentialValue` is missing or normalizes to an empty value.      |
| `400`  | `SCAN_ID_REQUIRED`          | Partner credential scan has no stable `scanId`.                    |
| `401`  | `DEVICE_NOT_AUTHORIZED`     | Device key is missing, malformed, inactive, or company mismatched. |

Client and auth errors use the application error envelope:

```json theme={null}
{
  "status": 400,
  "error": true,
  "message": "SCAN_ID_REQUIRED"
}
```

## Rate limit

Access validation is rate limited per access device.

| Setting             | Default  | Environment variable             |
| :------------------ | :------- | :------------------------------- |
| Window              | `1000ms` | `ACCESS_VALIDATE_RATE_WINDOW_MS` |
| Requests per window | `30`     | `ACCESS_VALIDATE_RATE_LIMIT`     |

When the rate limit is exceeded, the endpoint still returns HTTP `200` with a
denied access decision:

```json theme={null}
{
  "status": 200,
  "error": false,
  "message": "ACCESS_DECISION",
  "data": {
    "allowed": false,
    "reasonCode": "RATE_LIMITED",
    "reasonMessage": "Too many requests"
  }
}
```

## Security notes

* Store device keys as secrets on the edge device.
* Prefer `x-device-key`; legacy aliases are accepted only for compatibility.
* Do not log full QR tokens, credential values, device keys, or OAuth tokens.
* Use a stable `scanId` for partner credential retries to prevent duplicate
  physical scan processing.
* Treat any unknown `reasonCode` as denied.
