openapi: 3.1.0
info:
  title: KoreLock API
  version: "1.0"
  summary: Manage smart locks, credentials and access for KoreLock-powered devices.
  contact:
    name: KoreLock
    url: https://korelock.com
  # TODO(confirm): Claude Desktop shows connectors added on claude.ai (same account) without extra setup.
  # TODO(confirm): Which ChatGPT plans can add custom MCP servers, and whether workspace admins must allow it first.
  description: |
    The KoreLock API is a REST API for managing locks, locations and the people who can open them.
    Requests and responses use JSON, and every request is authenticated with an OAuth 2.0 access token.

    | | |
    |---|---|
    | **Base URL** | `https://api.korelock.com/v1` |
    | **Auth** | OAuth 2.0 (client credentials or authorization code + PKCE) |
    | **Format** | JSON over HTTPS |

    > **Using an AI assistant?** KoreLock has an MCP server, so Claude, ChatGPT and Codex can look up and manage your locks, people and schedules for you. See [Use with AI assistants](#description/use-with-ai-assistants).

    # Quick start

    This guide takes you from a new account to unlocking a lock with a PIN in five steps.

    ## 1. Get an access token

    1. [Create a KoreLock account](https://app.korelock.com/users/sign_up).
    2. In the web app, expand your user name in the left panel (account owners only) and click **OAuth Applications**.
    3. Click **New Application**, name it something like `QuickStart app`, and submit. Copy the generated `client_id` and `client_secret`.
    4. Exchange them for a token:

    ```bash
    curl -X POST https://api.korelock.com/oauth/token \
      -H "Content-Type: application/json" \
      -d '{
        "grant_type": "client_credentials",
        "client_id": "'"$CLIENT_ID"'",
        "client_secret": "'"$CLIENT_SECRET"'"
      }'
    ```

    Tokens last 2 hours (`expires_in: 7200`). Request a new one before it expires; an expired token returns `401 Unauthorized`.

    To check that it works, list your locks. A new account returns an empty `items` array:

    ```bash
    curl https://api.korelock.com/v1/locks \
      -H "Authorization: Bearer $AUTH_TOKEN"
    ```

    > The client credentials flow only reaches the account that registered the application. If you're building an integration that other KoreLock customers will connect to, use the [authorization code flow](#tag/authentication) instead.

    ## 2. Register a lock

    The quickest way to register a lock is the KoreLock Admin mobile app, which also connects the lock to Wi-Fi.

    1. Install the Admin app and sign in with your KoreLock account.
    2. Put batteries in the lock and keep your phone close to it.
    3. Tap **Register Lock**, pick the lock's serial number, name it, install any firmware update and connect it to Wi-Fi.

    Your lock now appears in [`GET /v1/locks`](#tag/locks/GET/v1/locks). You can also [register locks through the API](#tag/locks/POST/v1/locks).

    ## 3. Lock and unlock over Wi-Fi

    Send an unlock command with the lock's `id`:

    ```bash
    curl -X PUT https://api.korelock.com/v1/locks/$LOCK_ID/unlock \
      -H "Authorization: Bearer $AUTH_TOKEN"
    ```

    Commands are **queued**, not instant. To save battery, a lock's Wi-Fi sleeps between check-ins, so the command waits in `pending_state` until the lock next syncs. Any activity on the lock, such as a keypad press or card tap, wakes it to sync. Once the lock runs the command, `state` updates and `pending_state` clears. See [how changes reach a lock](#description/how-changes-reach-a-lock).

    ## 4. Give someone a PIN

    Create a credential holder with a PIN and a grant for your lock:

    ```bash
    curl -X POST https://api.korelock.com/v1/credential_holders \
      -H "Authorization: Bearer $AUTH_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "My PIN credential",
        "pin": "44556",
        "grants": [{ "lock_id": "'"$LOCK_ID"'" }]
      }'
    ```

    The grant starts with `sync_status: "pending"`. After the lock syncs it changes to `synced`, and typing `44556` on the keypad opens the lock.

    ## 5. Read lock events

    Every lock, unlock, credential sync and battery report creates an event:

    ```bash
    curl https://api.korelock.com/v1/locks/$LOCK_ID/events \
      -H "Authorization: Bearer $AUTH_TOKEN"
    ```

    > In production, use [webhooks](#tag/webhooks) rather than polling for events. Webhook payloads match the event format.

    # Use with AI assistants

    KoreLock has an MCP ([Model Context Protocol](https://modelcontextprotocol.io)) server, so you can ask an AI assistant about your locks in plain language. For example: "Which locks have a low battery?", "Who opened the front door this morning?" or "Give the cleaning crew access to the back door on weekdays."

    | | |
    |---|---|
    | **MCP server URL** | The address you sign in at, followed by `/mcp/v1`, such as `https://app.korelock.com/mcp/v1` |
    | **Sign-in** | OAuth with your KoreLock account |
    | **Works with** | Claude, Claude Desktop, Claude Code, ChatGPT and Codex |

    The steps below use `https://app.korelock.com/mcp/v1`. If your organization signs in at a different address, use that address instead. Other MCP clients aren't supported yet.

    Any KoreLock user can connect except guest admins. On accounts managed by an integration partner, the assistant can look things up but can't make changes.

    ## What the assistant can do

    What an assistant can do depends on the access you approve when you connect it. With read access only, it can look things up but can't change anything.

    ### Look things up

    - Get account details
    - List locations, locks and lock models
    - Get a lock's settings
    - List credential holders, and the grants for a credential holder or a lock
    - List SmartPINs
    - List schedules
    - List lock events

    ### Make changes

    With write access, the assistant can also:

    - **Locks:** register a lock by its serial number, rename it or move it to another location, and change most of its [settings](#tag/settings). It can't change the programming code, the SmartPIN seed or whether Wi-Fi is on.
    - **Locations:** create, update and delete locations
    - **Credential holders:** create, update and remove them, and grant or revoke their access to each lock
    - **SmartPINs:** create a SmartPIN, and change its name or email
    - **Schedules:** create, update and delete week, holiday, action and auto-lock schedules

    Changes reach a lock the same way as through the API: they're queued and apply when the lock next syncs. See [how changes reach a lock](#description/how-changes-reach-a-lock).

    ### Reveal hidden values

    PINs, card numbers, SmartPIN codes and lock programming codes are hidden in everything the assistant reads. With access to these values, the assistant can reveal them one at a time, and only when you ask:

    - The PIN, card number and UniKey card number of one credential holder
    - The code of one SmartPIN
    - The programming code of one lock

    Anyone with a lock's programming code can reconfigure the lock at its keypad, so share it with care. SmartPIN seeds and Bluetooth keys are never shown.

    ### Guardrails

    - **You choose the access.** When you connect an assistant, you sign in to KoreLock and approve what that connection can do. Looking things up is always included; making changes and revealing hidden values are off unless you tick them. Each connection has its own permissions.
    - **No lock or unlock commands, and no removing locks.** The MCP server has no tools to lock or unlock a door, or to unregister a lock, whatever the assistant is asked to do. Use the KoreLock app or the [API](#tag/locks) for that. With write access, though, it can set up [action schedules](#tag/action-schedules), which lock and unlock at set times.
    - **Limits.** Each user can make 30 MCP requests a minute. Turning on the Entry app sends an invitation, and invitations are limited to 20 an hour per account.
    - **Disconnecting.** An account owner can disconnect an assistant under **API Integrations** in the KoreLock web app by clicking **Revoke access**. That disconnects it for everyone on the account.
    - **Confirm before changes.** The tools tell the assistant to check details with you before it adds or removes access, registers a lock, or changes a location's time zone. Some changes also email people: turning on the Entry app sends the credential holder an invitation.

    ## Claude

    **Free, Pro, Max and Ultra plans:**

    1. In Claude, go to **Customize > Connectors**.
    2. Click **Add custom connector**.
    3. Name it `KoreLock` and enter the server URL: `https://app.korelock.com/mcp/v1`.
    4. For **Authentication**, choose **Sign in now**. For **OAuth client**, choose **Use Claude's published identity**.
    5. Click **Add**, then **Connect**. Sign in to KoreLock and approve access.

    **Team and Enterprise plans:** an Owner adds the connector once for the organization, then each member connects with their own KoreLock account.

    1. The Owner goes to **Organization settings > Connectors**, selects **Add**, then **Custom**, and chooses **Web** if asked.
    2. The Owner enters `https://app.korelock.com/mcp/v1` and clicks **Add**.
    3. Each member goes to **Customize > Connectors**, finds **KoreLock** (labelled **Custom**), clicks **Connect** and signs in to KoreLock.

    To use it in a chat, click **+**, open **Connectors** and make sure KoreLock is on.

    ## Claude Desktop

    The desktop app uses the same connectors as your Claude account. Add KoreLock as described under [Claude](#description/claude), in the app or at claude.ai, while signed in to the same account. It then shows up in the app's **+** menu under **Connectors**.

    ## ChatGPT

    1. In ChatGPT, go to [Plugins](https://chatgpt.com/plugins).
    2. Select **+**, then **Add custom MCP server**.
    3. Name it `KoreLock` and add a short description, such as "Look up KoreLock locks, people and events."
    4. Under **Connection**, enter `https://app.korelock.com/mcp/v1` and choose OAuth for authentication.
    5. Review the warning, select **I understand and want to continue**, then **Create as a plugin**.
    6. Sign in to KoreLock and approve access.

    To use it, start a new chat, type `@` and pick **KoreLock**. Whether you can add custom MCP servers depends on your ChatGPT plan and your workspace's policies; on a business workspace, an admin may need to allow it.

    ## Claude Code

    In a terminal, add the server, then sign in:

    ```bash
    claude mcp add --transport http korelock https://app.korelock.com/mcp/v1
    ```

    Then run `/mcp` in Claude Code, select **korelock** and approve access in your browser.

    ## Codex

    The Codex CLI, the Codex IDE extension and the ChatGPT desktop app share one configuration, so you only set this up once. In a terminal:

    ```bash
    codex mcp add korelock --url https://app.korelock.com/mcp/v1
    codex mcp login korelock
    ```

    `codex mcp login` opens your browser to sign in to KoreLock. On a machine without a browser, add `--no-browser`.

    Or add it to `~/.codex/config.toml` yourself, then run `codex mcp login korelock`:

    ```toml
    [mcp_servers.korelock]
    url = "https://app.korelock.com/mcp/v1"
    ```

    # API conventions

    ## Identifiers

    Every resource except lock models has a server-generated UUID `id`. You can't set it yourself: an `id`, or any other field an endpoint doesn't accept, in a request body returns `400` with `error_type: "unpermitted_params"`.

    To link a record to one in your own system, set its optional `external_uuid`. Locations, credential holders, grants and schedules accept one, and you can use `external_uuid:<your uuid>` in place of their `id` in a path.

    ## Pagination

    List endpoints return an `items` array and a `metadata` object. Use `page` and `per_page` (1–250, default 20) to page through results. Pages past the end return an empty `items` array, and a `page` or `per_page` out of range returns `400`. Event lists leave out `pages` and `total_count`; use `has_more_items` instead.

    ```json
    {
      "items": [ … ],
      "metadata": { "page": 1, "per_page": 20, "has_more_items": true, "pages": 7, "total_count": 124 }
    }
    ```

    ## Errors

    Errors return JSON with an `error_type` you can check in code and an `error_message` for people. A `400` for invalid data also lists each problem in `validation_errors`:

    ```json
    {
      "error_type": "validation_failed",
      "error_message": "Validation failed",
      "validation_errors": [
        { "attribute": "name", "type": "blank", "message": "Name can't be blank" }
      ]
    }
    ```

    `attribute` is `self` when the problem is with the record as a whole, such as a schedule that's still in use. The older `errors` array of messages is still returned but deprecated.

    ## How changes reach a lock

    To save battery, a lock's Wi-Fi sleeps between check-ins. So nothing you send through the API reaches a lock straight away: the API saves your change and **queues** it, and the lock applies it the next time it syncs.

    A lock syncs on its heartbeat ([`heartbeat_interval`](#tag/settings)) and, depending on its [`wake_wifi`](#tag/settings) setting, when someone uses it, such as a keypad press or card tap.

    Each kind of change shows that it's waiting in its own field:

    - **Lock and unlock commands:** the queued command is in the lock's `pending_state`. `state` keeps showing the last state the lock *reported* until it runs the command.
    - **Settings:** the lock's `pending_sync` is `true` while changes are waiting. It covers queued commands and credentials too.
    - **Grants**, including credentials and schedules applied through them: the grant's `sync_status` is `pending`, then `synced`, or `failed` if the lock couldn't apply it.

    Don't treat a change as done until the lock reports it. To hear about syncs as they happen, use [webhooks](#tag/webhooks).

    ## Rate limits

    Limits reset every minute. They're counted per user for tokens from the authorization code flow, and per account for client credentials tokens.

    | Requests | Limit |
    |---|---|
    | All writes (POST, PUT, DELETE combined) | 60 / minute |
    | Lock events | 30 / minute |
    | All other reads | 120 / minute |

    Over the limit, the API returns `429 Too Many Requests` with `error_type: "rate_limit_exceeded"` and a `Retry-After` header. `Retry-After` is always `60`.
servers:
  - url: https://api.korelock.com
    description: Production
security:
  - oauth2: []
  - bearerToken: []
tags:
  - name: Authentication
    description: |
      KoreLock uses OAuth 2.0. Pick the flow that matches what you're building:

      - **Client credentials**: for scripts and back-end services that only touch your own account.
      - **Authorization code**: for integrations where other KoreLock customers grant your app access to their accounts. Public apps (mobile, desktop, single-page) must add **PKCE**.

      ### Authorization code flow

      1. [Register an OAuth application](https://app.korelock.com/oauth/applications/new) with your redirect URL. Turn **Confidential** on only if the app runs on a server you control.
      2. Send the user to their brand's authorize URL:

         `https://[BRAND].webmanager.cloud/oauth/authorize?response_type=code&client_id=<client_id>&redirect_uri=<redirect_uri>`

         Each lock brand (an *Organization* in KoreLock) has its own login, so use the brand your customer signs in with. One OAuth application works across all brands.
      3. After the user clicks **Allow**, KoreLock redirects to your URL with `?code=…`.
      4. Exchange the code at [`POST /oauth/token`](#tag/authentication/POST/oauth/token) for an access token and refresh token.
      5. Refresh before the access token expires (`created_at + expires_in`).

      ### PKCE (required for public apps)

      Generate a random `code_verifier` for each authorization request and send its SHA-256 `code_challenge` on the authorize URL:

      ```bash
      CODE_VERIFIER=$(openssl rand -hex 32)
      CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
        | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=\n')
      ```

      Add `&code_challenge=<code_challenge>&code_challenge_method=S256` to the authorize URL, then send `code_verifier` instead of `client_secret` when you exchange the code.
  - name: Locations
    description: Locations group locks by physical place. Every lock belongs to exactly one location.
  # TODO(confirm): Where deadbolt calibration is actually done (no API setting or action exists for it).
  # TODO(confirm): entry_app_credential is false for every model in production. Should the docs say Entry app isn't available yet?
  - name: Lock models
    x-displayName: Models & capabilities
    description: |
      KoreLock powers locks from several product lines, from keypad locks to card-only locks. Each lock is a **model**, such as `K52-3505` or `PDQ-G1-C`, and each model has its own hardware: some have a keypad, some a card reader, some both.

      **Capabilities** describe what a model's hardware can do. Each one is a `true` or `false` flag on the model, in two groups:

      - **Credentials:** which ways of opening the lock it accepts, such as PINs, cards, SmartPINs or UniKey.
      - **Settings:** which optional [settings](#tag/settings) it supports, such as keypad lockout or an alarm.

      Check a lock's capabilities before you offer a feature for it. If a model doesn't support a setting, the lock's settings leave it out. Settings with no capability flag below, such as `auto_lock`, `mute` and `heartbeat_interval`, are returned for every model.

      A [grant](#tag/grants) can't be created for a lock that takes neither PINs nor cards. It also can't give a credential holder who has only a PIN access to a lock without PIN support, or one who has only a card access to a lock without card support. Credential types a lock doesn't support are skipped on that lock.

      ### Find a lock's capabilities

      A lock's `model_code` matches the `name` of its model.

      1. [Get the lock](#tag/locks/GET/v1/locks/{lock_id}) and read its `model_code`, such as `K52-3505`.
      2. [List models](#tag/lock-models/GET/v1/locks/models) and find the one with that `name`.

      ```bash
      MODEL=$(curl -s https://api.korelock.com/v1/locks/$LOCK_ID \
        -H "Authorization: Bearer $AUTH_TOKEN" | jq -r .model_code)

      curl -s "https://api.korelock.com/v1/locks/models?per_page=50" \
        -H "Authorization: Bearer $AUTH_TOKEN" \
        | jq --arg m "$MODEL" '.items[] | select(.name == $m)'
      ```

      ### Credential capabilities

      These flags say which credentials a lock accepts. Give a credential holder at least one credential type the lock supports.

      - **PIN** (`pin_credential`): a keypad code, set with the credential holder's `pin`.
      - **Card** (`smart_card_credential`): a card, set with the credential holder's `card_number`.
      - **SmartPIN** (`smart_pin_credential`): a time-limited guest PIN that works offline. See [SmartPINs](#tag/smartpins); also turn on the lock's `smart_pin_enabled` setting.
      - **UniKey** (`unikey_credential`): a UniKey mobile credential, set with the credential holder's `unikey_enabled`. Also enables the `unikey_reader` and `unikey_read_range_mode` settings.
      - **Entry app** (`entry_app_credential`): an Entry app credential, set with the credential holder's `entry_app_enabled`.

      ### Reader capabilities

      These flags describe the card readers built into or attached to the lock. Each one unlocks a setting that turns the reader on or off or picks its type.

      - **NFC reader** (`nfc_reader`): supports `nfc_antenna_enabled`.
      - **Proximity reader** (`prox_reader`): supports `prox_antenna_enabled`.
      - **Attached UniKey reader** (`unikey_reader_attached`): supports `edge_controller_reader_type`.

      ### Setting capabilities

      These flags say which optional [settings](#tag/settings) the lock supports.

      - **Programming code** (`programming_code`): the `programming_code` setting, for admin access from the keypad.
      - **Skip the # key** (`no_enter_code`): the `no_enter_code` setting.
      - **Keypad lockout** (`keypad_lockout`): `keypad_lockout_count` and `keypad_lockout_time`.
      - **Auto-lock after manual opening** (`manual_auto_lock`): `manual_auto_lock_timeout`.
      - **Wi-Fi wake without the knob** (`wake_wifi_except_manual`): the `user_action_except_manual` value of `wake_wifi`. Every current model supports it.
      - **Alarm** (`alarm`): `alarm_trigger_time` and `mute_alarm`.
      - **Always connected** (`always_on`): the `always_on` setting.
      - **Card byte order** (`card_byte_order`): the `card_byte_order` setting.
      - **NFC on key press** (`nfc_on_keypress`): the `nfc_on_keypress` setting. No current model supports it.
      - **Deadbolt calibration** (`deadbolt_calibration`): the model is a deadbolt that supports calibration. New locks of these models start with `auto_lock` set to `disabled`. There's no API setting for calibration.

      ### Example: keypad lock and card-only lock

      | | `K52-3505` | `PDQ-G1-C` |
      |---|---|---|
      | PIN | Yes | No |
      | Card | No | Yes |
      | SmartPIN | Yes | No |
      | UniKey | Yes | Yes |
      | NFC reader | No | Yes |
      | Keypad settings | Yes | No |

      The `K52-3505` has a keypad: it takes PINs and SmartPINs but has no card reader. The `PDQ-G1-C` takes cards only, so it doesn't accept PINs and its keypad settings (`programming_code`, `no_enter_code`, `keypad_lockout`) are `false`. A credential holder with only a PIN can be granted access to the first lock but not the second.

      ### Submodels

      Some product lines come in several submodels. A model's `name` includes the submodel (`K52-3505`), `submodel` holds it on its own (`3505`), and `submodel_number` is its numeric code. Models without submodels have `submodel: null`. When you [register a lock](#tag/locks/POST/v1/locks), send the submodel as `sub_model`.

      `mobile_credential` and `prox_nfc_reader` are deprecated. Use `entry_app_credential` and `prox_reader` instead.
  - name: Locks
    description: |
      A lock has **main attributes** you can set, like its name and location, and **state attributes** it reports on each sync, like battery level and whether it's locked.

      Commands such as lock and unlock are queued in `pending_state` and run the next time the lock syncs. `state` always reflects the last state the lock *reported*, so right after an unlock request you'll see `state: "locked"` with `pending_state: "unlock"`. See [how changes reach a lock](#description/how-changes-reach-a-lock).
  - name: Settings
    description: |
      Settings control how a lock behaves: auto-lock, sound, keypad lockout, card readers, Wi-Fi and more. Each lock has one set of settings.

      Not every model supports every setting. Check the lock's [model capabilities](#tag/lock-models) before you offer a setting. Settings that don't apply to a model are left out of the response.

      Setting changes are **queued**, like lock and unlock commands. The API saves them right away, but the lock applies them only when it next syncs. The lock's `pending_sync` is `true` while changes are waiting. See [how changes reach a lock](#description/how-changes-reach-a-lock).

      ### Replaced settings

      Some older settings have newer, more specific replacements. The old ones still appear in responses but are marked deprecated:

      | Deprecated | Use instead |
      |---|---|
      - `nfc`: `nfc_antenna_enabled` and `nfc_on_keypress`
      - `reader_frequency_mode`: `nfc_antenna_enabled` and `prox_antenna_enabled`
  - name: Credential holders
    description: |
      A credential holder is a person, or a shared role such as "Cleaning crew", who has one or more credentials: a PIN, a card, an Entry app credential or a UniKey mobile credential.

      A credential holder can't open anything until you give them a **grant** for a lock. You can add grants when you create the credential holder, or later through the [Grants](#tag/grants) endpoints.

      ### How credentials reach a lock

      Credentials are stored on each lock, so every grant has to sync to its lock. A new or changed grant starts as `pending`, then becomes `synced` once the lock next syncs, or `failed` if the lock couldn't apply it. The `total_pending`, `total_synced` and `total_failed` counts show where a credential holder's grants stand.

      Changing a credential holder's PIN, card, access times or schedules also has to sync to each of their locks, so those changes are **queued** too and their grants go back to `pending`. Changing only the name or email doesn't. Removing a credential holder or revoking a grant is queued the same way: the lock erases the credential when it next syncs. See [how changes reach a lock](#description/how-changes-reach-a-lock).

      ### Times

      `start_time` and `end_time` are local times at each lock, in `YYYY-MM-DDTHH:MM` format with no time zone. A credential holder with access to locks in different time zones gets the same wall-clock times at each one. Don't add a time zone offset.

      Responses include PINs and card numbers, so keep them private.

      ### Rules

      - `name` is required and must be unique in the account.
      - Every credential holder needs at least one credential: `pin`, `card_number`, `unikey_card_number`, `entry_app_enabled` or `unikey_enabled`.
      - `pin`, `card_number` and `unikey_card_number` must each be unique in the account.
      - `email` is required when `entry_app_enabled` or `unikey_enabled` is `true`, because KoreLock emails the person an invitation. Invitations are limited to 20 an hour per account; past that, requests that send one return `429`.
      - Your plan can limit how many credential holders you have, and whether UniKey is available. Over a limit, the request returns `400` (`billing_limited` or `feature_limited`).
      - `start_time` and `end_time` go together: set both or neither, with `end_time` after `start_time`.
      - `external_uuid` must be unique in the account.
  - name: Grants
    description: |
      A grant gives one credential holder access to one lock. Grants belong to a credential holder, so you create, update and revoke them under `/v1/credential_holders/{credential_holder_id}/grants`. You can also list the grants on a lock.

      ### Access times and schedules

      By default, a grant uses the credential holder's `start_time`, `end_time`, `week_schedule_id` and `holiday_schedule_id`. Set any of them on the grant to override the credential holder's value for that lock only.

      `expired` is worked out in the lock's local time.

      ### Syncing

      Grants are **queued**. A new or changed grant has `sync_status: "pending"` until the lock next syncs, then `synced`, or `failed` if the lock couldn't apply it. See [how changes reach a lock](#description/how-changes-reach-a-lock).

      ### Rules

      - A credential holder can have only one grant per lock, and a grant's `lock_id` can't be changed.
      - The lock must take PINs or cards. A credential holder with only a PIN needs a lock that takes PINs, and one with only a card needs a lock that takes cards. Credential types the lock doesn't support are skipped on that lock. Check the lock's [model capabilities](#tag/lock-models).
      - A grant's `external_uuid` must be unique in the account.

      Lists on a lock are sorted by credential holder name; lists for a credential holder are sorted by lock name.
  - name: SmartPINs
    description: |
      A SmartPIN is a time-limited PIN for guests that works **without a network connection**. Use it for short stays, contractors and other temporary access.

      ### How SmartPINs work

      KoreLock generates each SmartPIN from three things: a seed (by default, the lock's serial number), a start time and an end time. The access window is encoded in the PIN itself. When a guest types it in, the lock uses the same seed to decode it and check the time.

      Because nothing has to be sent to the lock, a SmartPIN works as soon as you create it, even if the lock is offline. That's why `sync_status` is always `ready`.

      To use SmartPINs, the lock's model needs the `smart_pin_credential` [capability](#tag/lock-models), and the lock's [`smart_pin_enabled` setting](#tag/settings) must be `true`. To let one SmartPIN open several locks, give them the same `smart_pin_seed` setting.

      ### Time rules

      SmartPIN times are whole hours (minutes must be `:00`). There are two kinds:

      | Kind | Length | Start and end times |
      |---|---|---|
      | Short-term | Up to 32 days | Any hour |
      | Long-term | 33 days to 1 year | Must start at 08:00 and end at 20:00 |

      ### SmartPINs are permanent

      After you create a SmartPIN, you can't change its times and you can't delete it, because the access window is built into the PIN. You can only change its `name` and `email`. Choose the times carefully.
  - name: Action schedules
    description: |
      Action schedules lock and unlock a lock automatically at set times each week. For example, unlock a lobby door at 08:00 on weekdays and lock it again at 18:00.

      Each entry is one of:

      - `unlock`: the lock unlocks itself.
      - `lock`: the lock locks itself.

      To use an action schedule, set the lock's [`action_schedule_id` setting](#tag/settings). Like other setting changes, it's queued and applies when the lock next syncs. See [how changes reach a lock](#description/how-changes-reach-a-lock).

      ### Schedule format

      All four schedule types use the same building blocks.

      **Weekly schedules** (action, auto-lock and week schedules) are a JSON object keyed by day. Leave out days with nothing scheduled.

      | Day | Key |
      |---|---|
      | Monday | `mon` |
      | Tuesday | `tue` |
      | Wednesday | `wed` |
      | Thursday | `thu` |
      | Friday | `fri` |
      | Saturday | `sat` |
      | Sunday | `sun` |

      Times are 24-hour `HH:MM`, such as `09:00` or `18:30`.

      For action and auto-lock schedules, each day holds a list of up to 10 entries. Each entry is a one-key object, such as `{ "unlock": "09:00" }`. Entries can be any mix of actions and don't need to come in pairs:

      ```json
      {
        "mon": [{ "unlock": "09:00" }, { "lock": "12:30" }],
        "tue": [{ "lock": "18:30" }],
        "wed": [{ "unlock": "09:00" }],
        "thu": [
          { "unlock": "10:00" },
          { "lock": "12:30" },
          { "unlock": "18:30" },
          { "lock": "22:30" }
        ]
      }
      ```

      This unlocks on Monday at 09:00 and locks at 12:30. It locks on Tuesday at 18:30 and stays locked until Wednesday at 09:00. On Thursday it unlocks at 10:00 and 18:30 and locks at 12:30 and 22:30.

      [Week schedules](#tag/week-schedules) use `start` and `end` intervals instead of actions. [Holiday schedules](#tag/holiday-schedules) are a list of date ranges rather than a weekly object.
  - name: Auto-lock schedules
    description: |
      Auto-lock schedules turn a lock's auto-lock on and off at set times each week. For example, keep a door unlocked during office hours and auto-locking after hours.

      Each entry is one of:

      - `auto-lock-on`: the lock starts auto-locking.
      - `auto-lock-off`: the lock stops auto-locking.

      To use an auto-lock schedule, set the lock's [`auto_lock` setting](#tag/settings) to `schedule` and `auto_lock_schedule_id` to the schedule's `id`. Like other setting changes, it's queued and applies when the lock next syncs. See [how changes reach a lock](#description/how-changes-reach-a-lock).

      Auto-lock schedules use the same weekly format as action schedules: up to 10 entries per day, in any order. See [schedule format](#tag/action-schedules).

      ```json
      {
        "tue": [{ "auto-lock-on": "08:00" }, { "auto-lock-off": "22:30" }],
        "wed": [{ "auto-lock-on": "17:30" }],
        "fri": [{ "auto-lock-off": "09:00" }],
        "sun": [
          { "auto-lock-on": "09:00" },
          { "auto-lock-off": "12:30" },
          { "auto-lock-on": "15:30" }
        ]
      }
      ```
  - name: Week schedules
    description: |
      Week schedules limit **when** a credential works. Outside the schedule's intervals, the credential holder's PIN or card doesn't open the lock.

      Apply a week schedule to a [credential holder](#tag/credential-holders) to cover all their locks, or to a single [grant](#tag/grants) to override it for one lock. Set `week_schedule_id` to the schedule's `id`.

      Week schedules use the same day keys and `HH:MM` times as the other [weekly schedules](#tag/action-schedules), but each day holds a list of `start` and `end` intervals:

      ```json
      {
        "tue": [
          { "start": "08:30", "end": "12:30" },
          { "start": "14:00", "end": "18:45" }
        ],
        "thu": [
          { "start": "08:30", "end": "12:30" },
          { "start": "14:00", "end": "18:45" }
        ]
      }
      ```

      This allows access on Tuesdays and Thursdays from 08:30 to 12:30 and from 14:00 to 18:45.

      A week schedule can have up to 40 intervals across the whole week.
  - name: Holiday schedules
    description: |
      Holiday schedules block credentials on certain **dates**. On those days, the credential holder's PIN or card doesn't open the lock.

      Apply a holiday schedule to a [credential holder](#tag/credential-holders) to cover all their locks, or to a single [grant](#tag/grants) to override it for one lock. Set `holiday_schedule_id` to the schedule's `id`.

      Unlike the [weekly schedules](#tag/action-schedules), a holiday schedule is a JSON **array** of date ranges. Each range has a `start` and `end` date in `YYYY-MM-DD` format, and both dates are included. For a single day, use the same date for both:

      ```json
      [
        { "start": "2026-11-26", "end": "2026-11-27" },
        { "start": "2026-12-25", "end": "2026-12-25" }
      ]
      ```

      `start` can't be after `end`. A holiday schedule can hold its 5 longest multi-day ranges as ranges, plus up to 40 other days: every other day, including the days in any further multi-day ranges, counts toward the 40. Ranges that have already ended don't count. Over the limit, the request returns `400` (`too_many_dates`).
  - name: Events
    description: |
      The lock creates an event every time something happens: an unlock, a denied PIN, a battery warning, a sync. Events are read-only.

      Every event has a `type` (the category) and an `action` (what happened). Some types add their own fields; access events, for example, say who opened the lock and how. Open an event type in the response schema to see its actions and fields.

      - `access`: Someone locked, unlocked or was denied at the lock.
      - `credential_holder_grant_sync`: A grant was added to or removed from the lock.
      - `battery`: The battery is low or was replaced.
      - `door_state`: The door sensor reported the door open, closed or unknown.
      - `reset`: The lock was reset or had codes erased.
      - `local_pin_sync`: A local PIN was added to or removed from the lock.
      - `reboot`: The lock powered on.

      `occurred_at` uses the lock's local UTC offset, so `2026-11-04T08:02:15-07:00` is 08:02 local time at the lock.

      To get events as they arrive instead of polling, use [webhooks](#tag/webhooks). Reading events has a lower rate limit than other reads (30 requests per minute).
  - name: Webhooks
    description: |
      A webhook tells KoreLock to send events to your server as they happen, so you don't have to poll. When a subscribed event occurs on any lock in your account, KoreLock sends it to your webhook's `url` as an HTTPS `POST`.

      The request body is the event itself, in the same format as [`GET /v1/locks/{lock_id}/events`](#tag/events/GET/v1/locks/{lock_id}/events).

      ### Which events are sent

      Webhooks deliver two event types:

      - `access`: locks, unlocks and denied attempts.
      - `credential_holder_grant_sync`: grants added to or removed from a lock.

      Set `subscribed_events` to any of these, or to `["*"]` for all of them. Other event types, such as `battery` or `door_state`, aren't sent by webhook; [list events](#tag/events) to read them.

      ### Verifying deliveries

      Set a `secret` on the webhook to sign each delivery. KoreLock then adds an `X-Hub-Signature` header: `sha256=` followed by the HMAC-SHA256 hex digest of the raw request body, keyed with your secret.

      Compute the same digest on your server and compare it before you trust the payload:

      ```js
      import crypto from "node:crypto";

      function isFromKoreLock(rawBody, signatureHeader, secret) {
        const expected = "sha256=" +
          crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
        return signatureHeader.length === expected.length &&
          crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
      }
      ```

      ### Disabled webhooks

      `disabled_by` says why a webhook stopped: `user` if someone set `enabled` to `false`, or `circuit_breaker` if KoreLock turned it off. Set `enabled` back to `true` to turn it on again.

      ### Delivery

      Reply with any `2xx` status within 3 seconds. KoreLock doesn't follow redirects. Any other response, or a timeout, is retried with increasing delays, up to 25 times over about 3 weeks. Retries stop if you disable the webhook.

      Deliveries can arrive more than once and out of order, so use the event's `id` to ignore duplicates.
x-tagGroups:
  - name: Getting started
    tags: [Authentication]
  - name: Resources
    tags: [Locations, Lock models, Locks, Settings]
  - name: Access & credentials
    tags: [Credential holders, Grants, SmartPINs]
  - name: Schedules
    tags: [Action schedules, Auto-lock schedules, Week schedules, Holiday schedules]
  - name: Events & webhooks
    tags: [Events, Webhooks]

paths:
  /oauth/token:
    post:
      tags: [Authentication]
      operationId: createToken
      summary: Get an access token
      description: |
        Exchanges client credentials, an authorization code or a refresh token for an access token. Access tokens last 7,200 seconds (2 hours).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/ClientCredentialsGrant"
                - $ref: "#/components/schemas/AuthorizationCodeGrant"
                - $ref: "#/components/schemas/RefreshTokenGrant"
            examples:
              clientCredentials:
                summary: Client credentials
                value:
                  grant_type: client_credentials
                  client_id: example_client_id_9f2c4a1e7b3d
                  client_secret: example_client_secret_5e8b1d6a2f4c
              authorizationCode:
                summary: Authorization code
                value:
                  grant_type: authorization_code
                  code: example_authorization_code_7d2b5f9a
                  client_id: example_client_id_3a7e9c2b5d1f
                  client_secret: example_client_secret_8c4f2a6e1b9d
                  redirect_uri: https://app.example.com/oauth/callback
              authorizationCodePkce:
                summary: Authorization code with PKCE
                value:
                  grant_type: authorization_code
                  code: example_authorization_code_7d2b5f9a
                  client_id: example_client_id_3a7e9c2b5d1f
                  code_verifier: 4f1c2a9e8b7d6c5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f
                  redirect_uri: https://app.example.com/oauth/callback
              refreshToken:
                summary: Refresh token
                value:
                  grant_type: refresh_token
                  refresh_token: example_refresh_token_6b1e8d3c4a7f
                  client_id: example_client_id_3a7e9c2b5d1f
                  client_secret: example_client_secret_8c4f2a6e1b9d
      responses:
        "200":
          description: Token issued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Token"
        "400":
          description: |
            The grant, code, refresh token or PKCE verifier is invalid. The body follows OAuth 2.0: `{ "error": "invalid_grant", "error_description": "…" }`.
        "401":
          description: 'The client ID or secret is wrong (`error: "invalid_client"`).'
  /oauth/revoke:
    post:
      tags: [Authentication]
      operationId: revokeToken
      summary: Revoke a token
      description: Revokes an access token or refresh token. Do this when a user signs out of your app, especially in mobile and desktop apps. It returns `200` even if the token was already revoked or unknown.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, token]
              properties:
                client_id:
                  type: string
                client_secret:
                  type: string
                  description: Required for confidential applications.
                token:
                  type: string
                  description: The access token to revoke.
            example:
              client_id: example_client_id_3a7e9c2b5d1f
              client_secret: example_client_secret_8c4f2a6e1b9d
              token: example_access_token_2f9a6c1e8b4d
      responses:
        "200":
          description: Token revoked.

  /v1/locations:
    get:
      tags: [Locations]
      operationId: listLocations
      summary: List locations
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of locations.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LocationList"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Locations]
      operationId: createLocation
      summary: Create a location
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LocationInput"
            example:
              external_uuid: 08cda068-2a82-4f25-9c61-471a196dfd72
              name: Denver HQ
              address: 1801 Wewatta St, Denver, CO
              timezone: America/Denver
      responses:
        "201":
          description: Location created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Location"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locations/{location_id}:
    parameters:
      - $ref: "#/components/parameters/location_id"
    get:
      tags: [Locations]
      operationId: getLocation
      summary: Get a location
      responses:
        "200":
          description: The location.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Location"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Locations]
      operationId: updateLocation
      summary: Update a location
      description: Changes the location's details. Changing `timezone` updates every lock at the location, which changes when their schedules run.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LocationUpdate"
            example:
              name: Denver HQ – Suite 200
              address: 1801 Wewatta St, Suite 200, Denver, CO
      responses:
        "200":
          description: The updated location.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Location"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Locations]
      operationId: deleteLocation
      summary: Delete a location
      description: Deletes the location. A location that still has registered locks can't be deleted; that returns `400` with a validation error of type `in_use`.
      responses:
        "200":
          description: Location deleted.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/locks/models:
    get:
      tags: [Lock models]
      operationId: listLockModels
      summary: List models and capabilities
      description: |
        Lists every lock model and its [capabilities](#tag/lock-models). Match a lock's `model_code` to a model's `name` to see what that lock supports.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of lock models.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/LockModel"
                  metadata:
                    $ref: "#/components/schemas/PageMetadata"
              example:
                items:
                  - name: K52-3505
                    submodel: "3505"
                    submodel_number: "3505"
                    pin_credential: true
                    smart_card_credential: false
                    smart_pin_credential: true
                    unikey_credential: true
                    entry_app_credential: false
                    nfc_reader: false
                    prox_reader: false
                    unikey_reader_attached: false
                    programming_code: true
                    no_enter_code: true
                    keypad_lockout: true
                    manual_auto_lock: true
                    wake_wifi_except_manual: true
                    alarm: false
                    always_on: false
                    card_byte_order: false
                    nfc_on_keypress: false
                    deadbolt_calibration: true
                    mobile_credential: false
                    prox_nfc_reader: false
                  - name: PDQ-G1-C
                    submodel: "G1-C"
                    submodel_number: "1005"
                    pin_credential: false
                    smart_card_credential: true
                    smart_pin_credential: false
                    unikey_credential: true
                    entry_app_credential: false
                    nfc_reader: true
                    prox_reader: false
                    unikey_reader_attached: false
                    programming_code: false
                    no_enter_code: false
                    keypad_lockout: false
                    manual_auto_lock: false
                    wake_wifi_except_manual: true
                    alarm: false
                    always_on: false
                    card_byte_order: false
                    nfc_on_keypress: false
                    deadbolt_calibration: false
                    mobile_credential: false
                    prox_nfc_reader: false
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: true
                  pages: 3
                  total_count: 43
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/locks:
    get:
      tags: [Locks]
      operationId: listLocks
      summary: List locks
      description: Lists every lock registered to the account.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: sort
          in: query
          description: Sort field. Prefix with `-` for descending order.
          schema:
            type: string
            default: name
            enum: [name, -name, created_at, -created_at, updated_at, -updated_at]
        - name: term
          in: query
          description: Filters by part of the lock's name or serial number, ignoring case. An unrecognised `sort` value sorts by name.
          schema:
            type: string
          example: Front door
      responses:
        "200":
          description: A page of locks.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LockList"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Locks]
      operationId: registerLock
      summary: Register a lock
      description: |
        Most locks are registered in the KoreLock white-label app, which also connects them to Wi-Fi. Use this endpoint when your own onboarding needs to register locks first.

        Models with submodels need `sub_model`:

        | Series | Submodels |
        |---|---|
        | K52 | 3505K, 3505, 3510 |
        | K72 | 5200, 5200K |
        | ENA | None |
        | JUN | None |
        | KCM | None |
        | KLB | None |
        | KLM | 8500, WU1DR |
        | PDQ | G1-C, G1-CK, G1-C-P, G1-CK-P, G2-C, G2-CK, G2-C-P, G2-CK-P, EX-C, EX-CK, EX-C-P, EX-CK-P, DB-C, DB-CK, DB-C-P, DB-CK-P, IN-C, IN-CK, IN-C-P, IN-CK-P, M01-C, M01-CK, M01-C-P, M01-CK-P, M03-C, M03-CK, M03-C-P, M03-CK-P |
        | NWT | None |
        | WST | 900JF, 900JQ, E07 |

        Some lock brands show their own model names. On those brands, send the submodel as it appears in that brand's [model list](#tag/lock-models/GET/v1/locks/models).

        Registration returns `400` if the serial number isn't recognised or is already registered, if the model and submodel don't match, if the name is taken at that location, or if your plan's lock limit is reached. `location_id` must be the location's `id`, not an `external_uuid`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LockRegistration"
            example:
              name: Front door
              serial_number: K52489515403
              sub_model: "3505"
              location_id: e3aec14d-ba7f-4966-99ca-137ef6f87782
      responses:
        "201":
          description: Lock registered.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Lock"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locks/{lock_id}:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    get:
      tags: [Locks]
      operationId: getLock
      summary: Get a lock
      description: Returns the lock's main and state attributes.
      responses:
        "200":
          description: The lock.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Lock"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Locks]
      operationId: updateLock
      summary: Update a lock
      description: Only `name` and `location_id` can be changed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LockUpdate"
            example:
              name: Back door
              location_id: e3aec14d-ba7f-4966-99ca-137ef6f87782
      responses:
        "200":
          description: The updated lock.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Lock"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Locks]
      operationId: deregisterLock
      summary: Deregister a lock
      description: |
        Removes the lock from your account.

        > **Warning:** This also removes the lock's grants, and deletes any credential holders who have no grants on other locks. That clean-up runs in the background a few moments after the response.
      responses:
        "200":
          description: Lock deregistered.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locks/{lock_id}/lock:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    put:
      tags: [Locks]
      operationId: lockLock
      summary: Lock
      description: Queues a lock command. `pending_state` becomes `lock` until the lock syncs and runs it, then `state` becomes `locked`.
      responses:
        "200":
          description: Command queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LockCommandResult"
              example:
                state: unlocked
                pending_state: lock
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locks/{lock_id}/unlock:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    put:
      tags: [Locks]
      operationId: unlockLock
      summary: Unlock
      description: Queues an unlock command. `pending_state` becomes `unlock` until the lock syncs and runs it, then `state` becomes `unlocked`.
      responses:
        "200":
          description: Command queued. `state` still shows the last reported state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LockCommandResult"
              example:
                state: locked
                pending_state: unlock
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locks/{lock_id}/cancel_pending_action:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    put:
      tags: [Locks]
      operationId: cancelPendingAction
      summary: Cancel a pending command
      description: Cancels a queued lock or unlock command and clears `pending_state`, as long as the lock hasn't fetched the command yet. If it already has, nothing changes and `pending_state` stays set, so check it in the response.
      responses:
        "200":
          description: Command cancelled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LockCommandResult"
              example:
                state: locked
                pending_state: null
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/locks/{lock_id}/settings:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    get:
      tags: [Settings]
      operationId: getLockSettings
      summary: Get settings
      description: Returns the lock's current settings. Settings the lock's model doesn't support are left out.
      responses:
        "200":
          description: The lock's settings.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LockSettings"
              example:
                action_schedule_id: null
                auto_lock: enabled
                auto_lock_schedule_id: null
                auto_lock_timeout: 30
                heartbeat_interval: 60
                keypad_lockout_count: 4
                keypad_lockout_time: 30
                manual_auto_lock_timeout: null
                mute: false
                no_enter_code: false
                online_auto_lock: true
                programming_code: "482915"
                smart_pin_enabled: false
                smart_pin_seed: "0000"
                unikey_read_range_mode: intermediate
                unikey_reader: disabled
                wake_wifi: user_action
                wifi_enabled: true
                created_at: "2026-09-02T18:20:11Z"
                updated_at: "2026-10-28T15:01:47Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Settings]
      operationId: updateLockSettings
      summary: Update settings
      description: |
        Changes the lock's settings. Send only the settings you want to change; the rest keep their values. The new values are saved and returned straight away, but the lock applies them only when it next syncs. Until then, the lock's `pending_sync` is `true`.

        Check the lock's [model capabilities](#tag/lock-models) first. Most settings a model doesn't support return `400` with a validation error. A few, such as `programming_code`, `no_enter_code` and the keypad lockout settings, are accepted but have no effect on models without that capability.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LockSettings"
            example:
              auto_lock: schedule
              auto_lock_schedule_id: 3f6d2a1e-7c84-4b59-9e0a-2d5c8b71f4a6
              auto_lock_timeout: 15
              keypad_lockout_count: 5
              keypad_lockout_time: 60
              mute: true
              no_enter_code: true
              smart_pin_enabled: true
              wake_wifi: user_action
      responses:
        "200":
          description: The updated settings. The lock applies them when it next syncs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LockSettings"
              example:
                action_schedule_id: null
                auto_lock: schedule
                auto_lock_schedule_id: 3f6d2a1e-7c84-4b59-9e0a-2d5c8b71f4a6
                auto_lock_timeout: 15
                heartbeat_interval: 60
                keypad_lockout_count: 5
                keypad_lockout_time: 60
                manual_auto_lock_timeout: null
                mute: true
                no_enter_code: true
                online_auto_lock: true
                programming_code: "482915"
                smart_pin_enabled: true
                smart_pin_seed: "0000"
                unikey_read_range_mode: intermediate
                unikey_reader: disabled
                wake_wifi: user_action
                wifi_enabled: true
                created_at: "2026-09-02T18:20:11Z"
                updated_at: "2026-11-04T10:02:51Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/credential_holders:
    get:
      tags: [Credential holders]
      operationId: listCredentialHolders
      summary: List credential holders
      description: |
        Lists the account's credential holders, sorted by name.

        The results don't include grants. To get them, [list the credential holder's grants](#tag/grants/GET/v1/credential_holders/{credential_holder_id}/grants).
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
        - name: term
          in: query
          description: |
            Filters the results. Matches part of a `name` or `email`, ignoring case. If the term is all digits, it also matches a `pin` or `card_number` exactly.
          schema:
            type: string
          example: Cleaning
      responses:
        "200":
          description: A page of credential holders.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialHolderList"
              example:
                items:
                  - id: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
                    external_uuid: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
                    name: Cleaning crew
                    email: cleaning@example.com
                    pin: "49820"
                    card_number: "4100094630"
                    unikey_enabled: true
                    start_time: "2026-11-01T08:30"
                    end_time: "2026-12-31T22:00"
                    week_schedule_id: 60c2623b-4577-4743-84af-5cc6407b21b1
                    holiday_schedule_id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
                    total_pending: 0
                    total_failed: 0
                    total_synced: 2
                    created_at: "2026-10-30T16:05:12Z"
                    updated_at: "2026-10-30T16:20:44Z"
                  - id: 7e2b5d18-9c43-4f6a-b0e1-3a8d6c9f2b57
                    external_uuid: 3acdd5ca-727c-49f4-8a66-f1145d8dc8b5
                    name: Front desk
                    email: frontdesk@example.com
                    pin: "94918"
                    total_pending: 1
                    total_failed: 0
                    total_synced: 8
                    created_at: "2026-09-14T13:41:09Z"
                    updated_at: "2026-11-02T09:12:30Z"
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Credential holders]
      operationId: createCredentialHolder
      summary: Create a credential holder
      description: |
        Creates a credential holder. To give them access straight away, add a `grants` array with one entry per lock. Each grant starts as `pending` and syncs to its lock the next time that lock syncs.

        If any grant can't be created, nothing is created and the response lists the problem as `Grant N: …`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CredentialHolderCreate"
            example:
              name: Cleaning crew
              external_uuid: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
              email: cleaning@example.com
              pin: "49820"
              card_number: "4100094630"
              unikey_enabled: true
              start_time: "2026-11-01T08:30"
              end_time: "2026-12-31T22:00"
              week_schedule_id: 60c2623b-4577-4743-84af-5cc6407b21b1
              holiday_schedule_id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
              grants:
                - lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                - lock_id: 2b7c4e91-6d3a-4f8e-a5b2-9c1d0e7f3a48
      responses:
        "201":
          description: Credential holder created, with their grants.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialHolderWithGrants"
              example:
                id: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
                external_uuid: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
                name: Cleaning crew
                email: cleaning@example.com
                pin: "49820"
                card_number: "4100094630"
                unikey_enabled: true
                start_time: "2026-11-01T08:30"
                end_time: "2026-12-31T22:00"
                week_schedule_id: 60c2623b-4577-4743-84af-5cc6407b21b1
                holiday_schedule_id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
                total_pending: 2
                total_failed: 0
                total_synced: 0
                created_at: "2026-10-30T16:05:12Z"
                updated_at: "2026-10-30T16:05:12Z"
                grants:
                  - id: e0d0db29-45a0-4a80-a438-9bf44e356f6a
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    sync_status: pending
                  - id: 0cd2cb2a-eced-4ab6-ad6c-30c23305d1e8
                    lock_id: 2b7c4e91-6d3a-4f8e-a5b2-9c1d0e7f3a48
                    sync_status: pending
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/credential_holders/{credential_holder_id}:
    parameters:
      - $ref: "#/components/parameters/credential_holder_id"
    get:
      tags: [Credential holders]
      operationId: getCredentialHolder
      summary: Get a credential holder
      description: |
        Returns a credential holder. The response doesn't include grants. To get them, [list the credential holder's grants](#tag/grants/GET/v1/credential_holders/{credential_holder_id}/grants).
      responses:
        "200":
          description: The credential holder.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialHolder"
              example:
                id: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
                external_uuid: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
                name: Cleaning crew
                email: cleaning@example.com
                pin: "49820"
                card_number: "4100094630"
                unikey_enabled: true
                start_time: "2026-11-01T08:30"
                end_time: "2026-12-31T22:00"
                week_schedule_id: 60c2623b-4577-4743-84af-5cc6407b21b1
                holiday_schedule_id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
                total_pending: 0
                total_failed: 0
                total_synced: 2
                created_at: "2026-10-30T16:05:12Z"
                updated_at: "2026-10-30T16:20:44Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Credential holders]
      operationId: updateCredentialHolder
      summary: Update a credential holder
      description: |
        Changes a credential holder's details and credentials. Changes to credentials and access times are **queued** and reach each lock when it next syncs.

        You can't change grants here. Use the [Grants](#tag/grants) endpoints to add or revoke access.

        Setting `unikey_enabled` to `false` revokes the person's UniKey credential on the UniKey servers.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CredentialHolderUpdate"
            example:
              pin: "845811"
              card_number: "8218744877"
              unikey_enabled: false
              start_time: "2026-11-01T08:30"
              end_time: "2027-01-31T22:00"
      responses:
        "200":
          description: The updated credential holder.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialHolder"
              example:
                id: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
                external_uuid: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
                name: Cleaning crew
                email: cleaning@example.com
                pin: "845811"
                card_number: "8218744877"
                unikey_enabled: false
                start_time: "2026-11-01T08:30"
                end_time: "2027-01-31T22:00"
                week_schedule_id: 60c2623b-4577-4743-84af-5cc6407b21b1
                holiday_schedule_id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
                total_pending: 2
                total_failed: 0
                total_synced: 0
                created_at: "2026-10-30T16:05:12Z"
                updated_at: "2026-11-04T10:02:51Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Credential holders]
      operationId: deleteCredentialHolder
      summary: Remove a credential holder
      description: Removes the credential holder and revokes all of their grants. Each lock erases their credentials the next time it syncs.
      responses:
        "200":
          description: Credential holder removed.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/credential_holders/{credential_holder_id}/grants:
    parameters:
      - $ref: "#/components/parameters/credential_holder_id"
    get:
      tags: [Grants]
      operationId: listCredentialHolderGrants
      summary: List grants for a credential holder
      description: Lists the credential holder's grants. Each grant includes its `lock`.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of grants, each with its lock.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CredentialHolderGrantList"
              example:
                items:
                  - id: e0d0db29-45a0-4a80-a438-9bf44e356f6a
                    external_uuid: 15c438f9-7554-4bd1-99b3-762234a9bf27
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    start_time: "2026-11-01T08:30"
                    end_time: "2026-12-31T22:00"
                    week_schedule_id: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
                    holiday_schedule_id: null
                    sync_status: pending
                    expired: false
                    lock:
                      id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                      name: Front door
                      serial_number: K52489515403
                      location_id: e3aec14d-ba7f-4966-99ca-137ef6f87782
                      model_code: K52-3505
                      firmware_version: nrf4.1.4+esp1.0.1
                      mac_address: "cf:7a:15:94:4c:7d"
                      battery_voltage: 540
                      secondary_wifi_enabled: true
                      state: locked
                      pending_state: null
                      state_updated_at: "2026-10-28T14:32:08Z"
                      alive: true
                      connected_at: "2026-10-28T15:01:47Z"
                      pending_sync: false
                      power_level: 85
                      wifi_level: 4
                      wifi_network_name: Office WiFi
                      created_at: "2026-09-02T18:20:11Z"
                      updated_at: "2026-10-28T15:01:47Z"
                  - id: 0cd2cb2a-eced-4ab6-ad6c-30c23305d1e8
                    external_uuid: null
                    lock_id: 2b7c4e91-6d3a-4f8e-a5b2-9c1d0e7f3a48
                    start_time: null
                    end_time: null
                    week_schedule_id: null
                    holiday_schedule_id: null
                    sync_status: synced
                    expired: false
                    lock:
                      id: 2b7c4e91-6d3a-4f8e-a5b2-9c1d0e7f3a48
                      name: Back door
                      serial_number: K52246414593
                      location_id: e3aec14d-ba7f-4966-99ca-137ef6f87782
                      model_code: K52-3505
                      firmware_version: nrf4.1.4+esp1.0.1
                      mac_address: "78:e0:64:fb:5b:a9"
                      battery_voltage: 540
                      secondary_wifi_enabled: true
                      state: unlocked
                      pending_state: null
                      state_updated_at: "2026-10-28T14:32:08Z"
                      alive: true
                      connected_at: "2026-10-28T15:01:47Z"
                      pending_sync: false
                      power_level: 85
                      wifi_level: 4
                      wifi_network_name: Office WiFi
                      created_at: "2026-09-02T18:20:11Z"
                      updated_at: "2026-10-28T15:01:47Z"
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Grants]
      operationId: createGrant
      summary: Create a grant
      description: |
        Gives the credential holder access to a lock. The grant starts as `pending` and syncs to the lock the next time it syncs.

        An unknown `lock_id` returns `400`, not `404`. An unknown or deleted schedule ID is ignored, so the grant uses the credential holder's schedule.

        Leave out the times and schedules to use the credential holder's. Set them to override the credential holder's values for this lock only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GrantCreate"
            example:
              external_uuid: 15c438f9-7554-4bd1-99b3-762234a9bf27
              lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
              start_time: "2026-11-01T08:30"
              end_time: "2026-12-31T22:00"
              week_schedule_id: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
      responses:
        "201":
          description: Grant created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantWithLock"
              example:
                id: e0d0db29-45a0-4a80-a438-9bf44e356f6a
                external_uuid: 15c438f9-7554-4bd1-99b3-762234a9bf27
                lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                start_time: "2026-11-01T08:30"
                end_time: "2026-12-31T22:00"
                week_schedule_id: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
                holiday_schedule_id: null
                sync_status: pending
                expired: false
                lock:
                  id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                  name: Front door
                  serial_number: K52489515403
                  location_id: e3aec14d-ba7f-4966-99ca-137ef6f87782
                  model_code: K52-3505
                  firmware_version: nrf4.1.4+esp1.0.1
                  mac_address: "cf:7a:15:94:4c:7d"
                  battery_voltage: 540
                  secondary_wifi_enabled: true
                  state: locked
                  pending_state: null
                  state_updated_at: "2026-10-28T14:32:08Z"
                  alive: true
                  connected_at: "2026-10-28T15:01:47Z"
                  pending_sync: false
                  power_level: 85
                  wifi_level: 4
                  wifi_network_name: Office WiFi
                  created_at: "2026-09-02T18:20:11Z"
                  updated_at: "2026-10-28T15:01:47Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/credential_holders/{credential_holder_id}/grants/{grant_id}:
    parameters:
      - $ref: "#/components/parameters/credential_holder_id"
      - $ref: "#/components/parameters/grant_id"
    get:
      tags: [Grants]
      operationId: getGrant
      summary: Get a grant
      description: Returns the grant, including its `lock`.
      responses:
        "200":
          description: The grant, with its lock.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GrantWithLock"
              example:
                id: e0d0db29-45a0-4a80-a438-9bf44e356f6a
                external_uuid: 15c438f9-7554-4bd1-99b3-762234a9bf27
                lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                start_time: "2026-11-01T08:30"
                end_time: "2026-12-31T22:00"
                week_schedule_id: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
                holiday_schedule_id: null
                sync_status: pending
                expired: false
                lock:
                  id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                  name: Front door
                  serial_number: K52489515403
                  location_id: e3aec14d-ba7f-4966-99ca-137ef6f87782
                  model_code: K52-3505
                  firmware_version: nrf4.1.4+esp1.0.1
                  mac_address: "cf:7a:15:94:4c:7d"
                  battery_voltage: 540
                  secondary_wifi_enabled: true
                  state: locked
                  pending_state: null
                  state_updated_at: "2026-10-28T14:32:08Z"
                  alive: true
                  connected_at: "2026-10-28T15:01:47Z"
                  pending_sync: false
                  power_level: 85
                  wifi_level: 4
                  wifi_network_name: Office WiFi
                  created_at: "2026-09-02T18:20:11Z"
                  updated_at: "2026-10-28T15:01:47Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Grants]
      operationId: updateGrant
      summary: Update a grant
      description: |
        Changes the grant's access times or schedules. The change is **queued** and reaches the lock when it next syncs.

        > **Send every override you want to keep.** `start_time`, `end_time`, `week_schedule_id` and `holiday_schedule_id` you leave out are cleared, so the grant falls back to the credential holder's values.

        `lock_id` can't be changed. To move access to another lock, revoke this grant and create a new one.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GrantUpdate"
            example:
              start_time: "2026-11-01T08:30"
              end_time: "2027-01-31T22:00"
              week_schedule_id: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
              holiday_schedule_id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
      responses:
        "200":
          description: The updated grant. The response doesn't include `lock`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Grant"
              example:
                id: e0d0db29-45a0-4a80-a438-9bf44e356f6a
                external_uuid: 15c438f9-7554-4bd1-99b3-762234a9bf27
                lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                start_time: "2026-11-01T08:30"
                end_time: "2027-01-31T22:00"
                week_schedule_id: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
                holiday_schedule_id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
                sync_status: pending
                expired: false
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Grants]
      operationId: revokeGrant
      summary: Revoke a grant
      description: |
        Removes the credential holder's access to the lock. The revoke is queued: the lock erases the credential when it next syncs.

        To revoke all of a person's grants at once, [remove the credential holder](#tag/credential-holders/DELETE/v1/credential_holders/{credential_holder_id}).
      responses:
        "200":
          description: Grant revoked.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locks/{lock_id}/grants:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    get:
      tags: [Grants]
      operationId: listLockGrants
      summary: List grants for a lock
      description: |
        Lists every grant on the lock, current and expired. Each grant includes its `credential_holder`.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of grants, each with its credential holder.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LockGrantList"
              example:
                items:
                  - id: e0d0db29-45a0-4a80-a438-9bf44e356f6a
                    external_uuid: 15c438f9-7554-4bd1-99b3-762234a9bf27
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    start_time: "2026-11-01T08:30"
                    end_time: "2026-12-31T22:00"
                    week_schedule_id: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
                    holiday_schedule_id: null
                    sync_status: pending
                    expired: false
                    credential_holder:
                      id: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
                      external_uuid: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
                      name: Cleaning crew
                      email: cleaning@example.com
                      pin: "49820"
                      card_number: "4100094630"
                      unikey_enabled: true
                      start_time: "2026-11-01T08:30"
                      end_time: "2026-12-31T22:00"
                      week_schedule_id: 60c2623b-4577-4743-84af-5cc6407b21b1
                      holiday_schedule_id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
                      total_pending: 1
                      total_failed: 0
                      total_synced: 1
                      created_at: "2026-10-30T16:05:12Z"
                      updated_at: "2026-10-30T16:20:44Z"
                  - id: a385ad21-9164-43f4-af33-a4bb279556ef
                    external_uuid: null
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    start_time: null
                    end_time: null
                    week_schedule_id: null
                    holiday_schedule_id: null
                    sync_status: synced
                    expired: false
                    credential_holder:
                      id: 7e2b5d18-9c43-4f6a-b0e1-3a8d6c9f2b57
                      external_uuid: 3acdd5ca-727c-49f4-8a66-f1145d8dc8b5
                      name: Front desk
                      email: frontdesk@example.com
                      pin: "94918"
                      card_number: null
                      unikey_enabled: false
                      start_time: null
                      end_time: null
                      week_schedule_id: null
                      holiday_schedule_id: null
                      total_pending: 0
                      total_failed: 0
                      total_synced: 9
                      created_at: "2026-09-14T13:41:09Z"
                      updated_at: "2026-11-02T09:12:30Z"
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locks/{lock_id}/grants/current:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    get:
      tags: [Grants]
      operationId: listLockCurrentGrants
      summary: List current grants for a lock
      description: |
        Lists the lock's grants that haven't expired (`expired: false`), including ones that haven't started yet. Each grant includes its `credential_holder`.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of grants, each with its credential holder.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LockGrantList"
              example:
                items:
                  - id: e0d0db29-45a0-4a80-a438-9bf44e356f6a
                    external_uuid: 15c438f9-7554-4bd1-99b3-762234a9bf27
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    start_time: "2026-11-01T08:30"
                    end_time: "2026-12-31T22:00"
                    week_schedule_id: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
                    holiday_schedule_id: null
                    sync_status: pending
                    expired: false
                    credential_holder:
                      id: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
                      external_uuid: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
                      name: Cleaning crew
                      email: cleaning@example.com
                      pin: "49820"
                      card_number: "4100094630"
                      unikey_enabled: true
                      start_time: "2026-11-01T08:30"
                      end_time: "2026-12-31T22:00"
                      week_schedule_id: 60c2623b-4577-4743-84af-5cc6407b21b1
                      holiday_schedule_id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
                      total_pending: 1
                      total_failed: 0
                      total_synced: 1
                      created_at: "2026-10-30T16:05:12Z"
                      updated_at: "2026-10-30T16:20:44Z"
                  - id: a385ad21-9164-43f4-af33-a4bb279556ef
                    external_uuid: null
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    start_time: null
                    end_time: null
                    week_schedule_id: null
                    holiday_schedule_id: null
                    sync_status: synced
                    expired: false
                    credential_holder:
                      id: 7e2b5d18-9c43-4f6a-b0e1-3a8d6c9f2b57
                      external_uuid: 3acdd5ca-727c-49f4-8a66-f1145d8dc8b5
                      name: Front desk
                      email: frontdesk@example.com
                      pin: "94918"
                      card_number: null
                      unikey_enabled: false
                      start_time: null
                      end_time: null
                      week_schedule_id: null
                      holiday_schedule_id: null
                      total_pending: 0
                      total_failed: 0
                      total_synced: 9
                      created_at: "2026-09-14T13:41:09Z"
                      updated_at: "2026-11-02T09:12:30Z"
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locks/{lock_id}/grants/expired:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    get:
      tags: [Grants]
      operationId: listLockExpiredGrants
      summary: List expired grants for a lock
      description: |
        Lists the lock's expired grants (`expired: true`), worked out in the lock's local time. Each grant includes its `credential_holder`.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of grants, each with its credential holder.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LockGrantList"
              example:
                items:
                  - id: 5d2e8f71-3c94-4a0b-b6e2-9f1a7c4d8e30
                    external_uuid: null
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    start_time: "2026-09-01T08:00"
                    end_time: "2026-10-31T18:00"
                    week_schedule_id: null
                    holiday_schedule_id: null
                    sync_status: synced
                    expired: true
                    credential_holder:
                      id: 7e2b5d18-9c43-4f6a-b0e1-3a8d6c9f2b57
                      external_uuid: 3acdd5ca-727c-49f4-8a66-f1145d8dc8b5
                      name: Front desk
                      email: frontdesk@example.com
                      pin: "94918"
                      card_number: null
                      unikey_enabled: false
                      start_time: null
                      end_time: null
                      week_schedule_id: null
                      holiday_schedule_id: null
                      total_pending: 0
                      total_failed: 0
                      total_synced: 9
                      created_at: "2026-09-14T13:41:09Z"
                      updated_at: "2026-11-02T09:12:30Z"
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 1
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/locks/{lock_id}/smart_pins:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    get:
      tags: [SmartPINs]
      operationId: listSmartPins
      summary: List SmartPINs
      description: Lists every SmartPIN on the lock, current and expired. The order isn't guaranteed.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of SmartPINs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SmartPinList"
              example:
                items:
                  - id: cc47b6ad-aac6-4e99-8bab-3d448f43e4e8
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    name: Guest, unit 204
                    email: guest204@example.com
                    pin: "4418850246"
                    start_time: "2026-11-14T15:00"
                    end_time: "2026-11-21T11:00"
                    expired: false
                    type: smart_pin_credential
                    sync_status: ready
                  - id: ee00146f-3f40-4f1f-818b-58187c8bb272
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    name: HVAC contractor
                    email: hvac@example.com
                    pin: "6526592091"
                    start_time: "2026-11-01T08:00"
                    end_time: "2027-02-28T20:00"
                    expired: false
                    type: smart_pin_credential
                    sync_status: ready
                  - id: 7a1d3e5f-9b2c-4d6e-8f0a-1c3e5a7b9d2f
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    name: Weekend guest
                    email: weekend@example.com
                    pin: "8130472265"
                    start_time: "2026-10-09T16:00"
                    end_time: "2026-10-12T10:00"
                    expired: true
                    type: smart_pin_credential
                    sync_status: ready
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 3
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [SmartPINs]
      operationId: createSmartPin
      summary: Create a SmartPIN
      description: |
        Generates a 10-digit SmartPIN for the lock. It works straight away, even if the lock is offline.

        Times must be whole hours. Short-term SmartPINs last up to 32 days and can use any hour. Long-term SmartPINs last 33 days to a year and must run from 08:00 to 20:00. See [time rules](#tag/smartpins).

        > **You can't delete a SmartPIN or change its times after you create it.**
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SmartPinCreate"
            examples:
              shortTerm:
                summary: Short-term (up to 32 days)
                value:
                  name: Guest, unit 204
                  email: guest204@example.com
                  start_time: "2026-11-14T15:00"
                  end_time: "2026-11-21T11:00"
              longTerm:
                summary: Long-term (08:00 to 20:00)
                value:
                  name: HVAC contractor
                  email: hvac@example.com
                  start_time: "2026-11-01T08:00"
                  end_time: "2027-02-28T20:00"
      responses:
        "201":
          description: SmartPIN created. Share `pin` with the guest.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SmartPin"
              example:
                id: cc47b6ad-aac6-4e99-8bab-3d448f43e4e8
                lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                name: Guest, unit 204
                email: guest204@example.com
                pin: "4418850246"
                start_time: "2026-11-14T15:00"
                end_time: "2026-11-21T11:00"
                expired: false
                type: smart_pin_credential
                sync_status: ready
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locks/{lock_id}/smart_pins/current:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    get:
      tags: [SmartPINs]
      operationId: listCurrentSmartPins
      summary: List current SmartPINs
      description: |
        Lists the lock's SmartPINs that haven't expired (`expired: false`).
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of SmartPINs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SmartPinList"
              example:
                items:
                  - id: cc47b6ad-aac6-4e99-8bab-3d448f43e4e8
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    name: Guest, unit 204
                    email: guest204@example.com
                    pin: "4418850246"
                    start_time: "2026-11-14T15:00"
                    end_time: "2026-11-21T11:00"
                    expired: false
                    type: smart_pin_credential
                    sync_status: ready
                  - id: ee00146f-3f40-4f1f-818b-58187c8bb272
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    name: HVAC contractor
                    email: hvac@example.com
                    pin: "6526592091"
                    start_time: "2026-11-01T08:00"
                    end_time: "2027-02-28T20:00"
                    expired: false
                    type: smart_pin_credential
                    sync_status: ready
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locks/{lock_id}/smart_pins/expired:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    get:
      tags: [SmartPINs]
      operationId: listExpiredSmartPins
      summary: List expired SmartPINs
      description: |
        Lists the lock's expired SmartPINs (`expired: true`), worked out in the lock's local time.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of SmartPINs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SmartPinList"
              example:
                items:
                  - id: 7a1d3e5f-9b2c-4d6e-8f0a-1c3e5a7b9d2f
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    name: Weekend guest
                    email: weekend@example.com
                    pin: "8130472265"
                    start_time: "2026-10-09T16:00"
                    end_time: "2026-10-12T10:00"
                    expired: true
                    type: smart_pin_credential
                    sync_status: ready
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 1
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locks/{lock_id}/smart_pins/{smart_pin_id}:
    parameters:
      - $ref: "#/components/parameters/lock_id"
      - $ref: "#/components/parameters/smart_pin_id"
    get:
      tags: [SmartPINs]
      operationId: getSmartPin
      summary: Get a SmartPIN
      description: Returns a SmartPIN, including its `pin`.
      responses:
        "200":
          description: The SmartPIN.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SmartPin"
              example:
                id: cc47b6ad-aac6-4e99-8bab-3d448f43e4e8
                lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                name: Guest, unit 204
                email: guest204@example.com
                pin: "4418850246"
                start_time: "2026-11-14T15:00"
                end_time: "2026-11-21T11:00"
                expired: false
                type: smart_pin_credential
                sync_status: ready
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [SmartPINs]
      operationId: updateSmartPin
      summary: Update a SmartPIN
      description: |
        Changes a SmartPIN's `name` or `email`. Nothing else can be changed, because the times are encoded in the PIN.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SmartPinUpdate"
            example:
              name: Guest, unit 204 (extended)
              email: guest204-alt@example.com
      responses:
        "200":
          description: The updated SmartPIN.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SmartPin"
              example:
                id: cc47b6ad-aac6-4e99-8bab-3d448f43e4e8
                lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                name: Guest, unit 204 (extended)
                email: guest204-alt@example.com
                pin: "4418850246"
                start_time: "2026-11-14T15:00"
                end_time: "2026-11-21T11:00"
                expired: false
                type: smart_pin_credential
                sync_status: ready
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/action_schedules:
    get:
      tags: [Action schedules]
      operationId: listActionSchedules
      summary: List action schedules
      description: Sorted by name.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of action schedules.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ActionScheduleList"
              example:
                items:
                  - id: 93d52ee7-6fbe-4432-818f-47846fa0e21a
                    external_uuid: 522a9219-d6ea-4f92-ae7a-e1f8e3eae053
                    account_id: f7c03f5d-625a-45f4-abe5-d755553bd370
                    name: Lobby open hours
                    schedule:
                      mon:
                        - unlock: "08:00"
                        - lock: "18:00"
                      tue:
                        - unlock: "08:00"
                        - lock: "18:00"
                      wed:
                        - unlock: "08:00"
                        - lock: "18:00"
                      thu:
                        - unlock: "08:00"
                        - lock: "18:00"
                      fri:
                        - unlock: "08:00"
                        - lock: "16:00"
                  - id: fe4b6e19-73bb-42e1-b04c-7715f9b99b5b
                    external_uuid: ce452bbc-45e1-4d34-b018-9f2fe76e9afd
                    account_id: f7c03f5d-625a-45f4-abe5-d755553bd370
                    name: Saturday deliveries
                    schedule:
                      sat:
                        - unlock: "10:00"
                        - lock: "12:00"
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Action schedules]
      operationId: createActionSchedule
      summary: Create an action schedule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ActionScheduleInput"
            example:
              name: Lobby open hours
              external_uuid: 522a9219-d6ea-4f92-ae7a-e1f8e3eae053
              schedule:
                mon:
                  - unlock: "08:00"
                  - lock: "18:00"
                tue:
                  - unlock: "08:00"
                  - lock: "18:00"
                wed:
                  - unlock: "08:00"
                  - lock: "18:00"
                thu:
                  - unlock: "08:00"
                  - lock: "18:00"
                fri:
                  - unlock: "08:00"
                  - lock: "16:00"
      responses:
        "201":
          description: Schedule created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ActionSchedule"
              example:
                id: 93d52ee7-6fbe-4432-818f-47846fa0e21a
                external_uuid: 522a9219-d6ea-4f92-ae7a-e1f8e3eae053
                account_id: f7c03f5d-625a-45f4-abe5-d755553bd370
                name: Lobby open hours
                schedule:
                  mon:
                    - unlock: "08:00"
                    - lock: "18:00"
                  tue:
                    - unlock: "08:00"
                    - lock: "18:00"
                  wed:
                    - unlock: "08:00"
                    - lock: "18:00"
                  thu:
                    - unlock: "08:00"
                    - lock: "18:00"
                  fri:
                    - unlock: "08:00"
                    - lock: "16:00"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/action_schedules/{action_schedule_id}:
    parameters:
      - $ref: "#/components/parameters/action_schedule_id"
    get:
      tags: [Action schedules]
      operationId: getActionSchedule
      summary: Get an action schedule
      responses:
        "200":
          description: The action schedule.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ActionSchedule"
              example:
                id: 93d52ee7-6fbe-4432-818f-47846fa0e21a
                external_uuid: 522a9219-d6ea-4f92-ae7a-e1f8e3eae053
                account_id: f7c03f5d-625a-45f4-abe5-d755553bd370
                name: Lobby open hours
                schedule:
                  mon:
                    - unlock: "08:00"
                    - lock: "18:00"
                  tue:
                    - unlock: "08:00"
                    - lock: "18:00"
                  wed:
                    - unlock: "08:00"
                    - lock: "18:00"
                  thu:
                    - unlock: "08:00"
                    - lock: "18:00"
                  fri:
                    - unlock: "08:00"
                    - lock: "16:00"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Action schedules]
      operationId: updateActionSchedule
      summary: Update an action schedule
      description: |
        Changes the schedule's name or times. Fields you leave out keep their values. If you send `schedule`, it replaces the old one in full, and locks that use the schedule get the new times on their next sync.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ActionScheduleUpdate"
            example:
              schedule:
                mon:
                  - unlock: "07:30"
                  - lock: "18:30"
                fri:
                  - unlock: "07:30"
                  - lock: "16:00"
      responses:
        "200":
          description: The updated action schedule.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ActionSchedule"
              example:
                id: 93d52ee7-6fbe-4432-818f-47846fa0e21a
                external_uuid: 522a9219-d6ea-4f92-ae7a-e1f8e3eae053
                account_id: f7c03f5d-625a-45f4-abe5-d755553bd370
                name: Lobby open hours
                schedule:
                  mon:
                    - unlock: "07:30"
                    - lock: "18:30"
                  fri:
                    - unlock: "07:30"
                    - lock: "16:00"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Action schedules]
      operationId: deleteActionSchedule
      summary: Remove an action schedule
      description: |
        Removes the schedule. It must not be in use by any lock, or the request returns `400` with a validation error of type `in_use`.
      responses:
        "200":
          description: Schedule removed.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/auto_lock_schedules:
    get:
      tags: [Auto-lock schedules]
      operationId: listAutoLockSchedules
      summary: List auto-lock schedules
      description: Sorted by name.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of auto-lock schedules.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoLockScheduleList"
              example:
                items:
                  - id: 3f6d2a1e-7c84-4b59-9e0a-2d5c8b71f4a6
                    external_uuid: 0b8e4c2a-6f1d-4e93-a7b5-c2d9e0f13a64
                    account_id: f7c03f5d-625a-45f4-abe5-d755553bd370
                    name: After hours
                    schedule:
                      mon:
                        - auto-lock-on: "18:00"
                      tue:
                        - auto-lock-off: "08:00"
                        - auto-lock-on: "18:00"
                      wed:
                        - auto-lock-off: "08:00"
                        - auto-lock-on: "18:00"
                      thu:
                        - auto-lock-off: "08:00"
                        - auto-lock-on: "18:00"
                      fri:
                        - auto-lock-off: "08:00"
                        - auto-lock-on: "18:00"
                      sat:
                        - auto-lock-off: "08:00"
                  - id: d4a7f2c9-1e8b-4b36-9c50-7e2f6a8d3b15
                    external_uuid: null
                    account_id: f7c03f5d-625a-45f4-abe5-d755553bd370
                    name: Weekend events
                    schedule:
                      sat:
                        - auto-lock-off: "09:00"
                        - auto-lock-on: "23:00"
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Auto-lock schedules]
      operationId: createAutoLockSchedule
      summary: Create an auto-lock schedule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AutoLockScheduleInput"
            example:
              name: After hours
              external_uuid: 0b8e4c2a-6f1d-4e93-a7b5-c2d9e0f13a64
              schedule:
                mon:
                  - auto-lock-on: "18:00"
                tue:
                  - auto-lock-off: "08:00"
                  - auto-lock-on: "18:00"
                wed:
                  - auto-lock-off: "08:00"
                  - auto-lock-on: "18:00"
                thu:
                  - auto-lock-off: "08:00"
                  - auto-lock-on: "18:00"
                fri:
                  - auto-lock-off: "08:00"
                  - auto-lock-on: "18:00"
                sat:
                  - auto-lock-off: "08:00"
      responses:
        "201":
          description: Schedule created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoLockSchedule"
              example:
                id: 3f6d2a1e-7c84-4b59-9e0a-2d5c8b71f4a6
                external_uuid: 0b8e4c2a-6f1d-4e93-a7b5-c2d9e0f13a64
                account_id: f7c03f5d-625a-45f4-abe5-d755553bd370
                name: After hours
                schedule:
                  mon:
                    - auto-lock-on: "18:00"
                  tue:
                    - auto-lock-off: "08:00"
                    - auto-lock-on: "18:00"
                  wed:
                    - auto-lock-off: "08:00"
                    - auto-lock-on: "18:00"
                  thu:
                    - auto-lock-off: "08:00"
                    - auto-lock-on: "18:00"
                  fri:
                    - auto-lock-off: "08:00"
                    - auto-lock-on: "18:00"
                  sat:
                    - auto-lock-off: "08:00"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/auto_lock_schedules/{auto_lock_schedule_id}:
    parameters:
      - $ref: "#/components/parameters/auto_lock_schedule_id"
    get:
      tags: [Auto-lock schedules]
      operationId: getAutoLockSchedule
      summary: Get an auto-lock schedule
      responses:
        "200":
          description: The auto-lock schedule.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoLockSchedule"
              example:
                id: 3f6d2a1e-7c84-4b59-9e0a-2d5c8b71f4a6
                external_uuid: 0b8e4c2a-6f1d-4e93-a7b5-c2d9e0f13a64
                account_id: f7c03f5d-625a-45f4-abe5-d755553bd370
                name: After hours
                schedule:
                  mon:
                    - auto-lock-on: "18:00"
                  tue:
                    - auto-lock-off: "08:00"
                    - auto-lock-on: "18:00"
                  wed:
                    - auto-lock-off: "08:00"
                    - auto-lock-on: "18:00"
                  thu:
                    - auto-lock-off: "08:00"
                    - auto-lock-on: "18:00"
                  fri:
                    - auto-lock-off: "08:00"
                    - auto-lock-on: "18:00"
                  sat:
                    - auto-lock-off: "08:00"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Auto-lock schedules]
      operationId: updateAutoLockSchedule
      summary: Update an auto-lock schedule
      description: |
        Changes the schedule's name or times. Fields you leave out keep their values. If you send `schedule`, it replaces the old one in full, and locks that use the schedule get the new times on their next sync.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AutoLockScheduleUpdate"
            example:
              schedule:
                mon:
                  - auto-lock-on: "19:00"
                tue:
                  - auto-lock-off: "07:30"
                  - auto-lock-on: "19:00"
      responses:
        "200":
          description: The updated auto-lock schedule.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoLockSchedule"
              example:
                id: 3f6d2a1e-7c84-4b59-9e0a-2d5c8b71f4a6
                external_uuid: 0b8e4c2a-6f1d-4e93-a7b5-c2d9e0f13a64
                account_id: f7c03f5d-625a-45f4-abe5-d755553bd370
                name: After hours
                schedule:
                  mon:
                    - auto-lock-on: "19:00"
                  tue:
                    - auto-lock-off: "07:30"
                    - auto-lock-on: "19:00"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Auto-lock schedules]
      operationId: deleteAutoLockSchedule
      summary: Remove an auto-lock schedule
      description: |
        Removes the schedule. It must not be in use by any lock, or the request returns `400` with a validation error of type `in_use`.
      responses:
        "200":
          description: Schedule removed.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/week_schedules:
    get:
      tags: [Week schedules]
      operationId: listWeekSchedules
      summary: List week schedules
      description: Sorted by name.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of week schedules.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WeekScheduleList"
              example:
                items:
                  - id: 60c2623b-4577-4743-84af-5cc6407b21b1
                    external_uuid: 2d9f6b1e-8a3c-4e57-b0d4-6c1a9e7f2b83
                    name: Office hours
                    schedule:
                      mon:
                        - start: "08:00"
                          end: "18:00"
                      tue:
                        - start: "08:00"
                          end: "18:00"
                      wed:
                        - start: "08:00"
                          end: "18:00"
                      thu:
                        - start: "08:00"
                          end: "18:00"
                      fri:
                        - start: "08:00"
                          end: "16:00"
                  - id: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
                    external_uuid: null
                    name: Weekends only
                    schedule:
                      sat:
                        - start: "09:00"
                          end: "17:00"
                      sun:
                        - start: "09:00"
                          end: "17:00"
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Week schedules]
      operationId: createWeekSchedule
      summary: Create a week schedule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WeekScheduleInput"
            example:
              name: Office hours
              external_uuid: 2d9f6b1e-8a3c-4e57-b0d4-6c1a9e7f2b83
              schedule:
                mon:
                  - start: "08:00"
                    end: "18:00"
                tue:
                  - start: "08:00"
                    end: "18:00"
                wed:
                  - start: "08:00"
                    end: "18:00"
                thu:
                  - start: "08:00"
                    end: "18:00"
                fri:
                  - start: "08:00"
                    end: "16:00"
      responses:
        "201":
          description: Schedule created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WeekSchedule"
              example:
                id: 60c2623b-4577-4743-84af-5cc6407b21b1
                external_uuid: 2d9f6b1e-8a3c-4e57-b0d4-6c1a9e7f2b83
                name: Office hours
                schedule:
                  mon:
                    - start: "08:00"
                      end: "18:00"
                  tue:
                    - start: "08:00"
                      end: "18:00"
                  wed:
                    - start: "08:00"
                      end: "18:00"
                  thu:
                    - start: "08:00"
                      end: "18:00"
                  fri:
                    - start: "08:00"
                      end: "16:00"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/week_schedules/{week_schedule_id}:
    parameters:
      - $ref: "#/components/parameters/week_schedule_id"
    get:
      tags: [Week schedules]
      operationId: getWeekSchedule
      summary: Get a week schedule
      responses:
        "200":
          description: The week schedule.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WeekSchedule"
              example:
                id: 60c2623b-4577-4743-84af-5cc6407b21b1
                external_uuid: 2d9f6b1e-8a3c-4e57-b0d4-6c1a9e7f2b83
                name: Office hours
                schedule:
                  mon:
                    - start: "08:00"
                      end: "18:00"
                  tue:
                    - start: "08:00"
                      end: "18:00"
                  wed:
                    - start: "08:00"
                      end: "18:00"
                  thu:
                    - start: "08:00"
                      end: "18:00"
                  fri:
                    - start: "08:00"
                      end: "16:00"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Week schedules]
      operationId: updateWeekSchedule
      summary: Update a week schedule
      description: |
        Changes the schedule's name or times. Fields you leave out keep their values. If you send `schedule`, it replaces the old one in full, and locks that use the schedule get the new times on their next sync.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WeekScheduleUpdate"
            example:
              schedule:
                tue:
                  - start: "08:30"
                    end: "12:30"
                  - start: "14:00"
                    end: "18:45"
                thu:
                  - start: "08:30"
                    end: "12:30"
                  - start: "14:00"
                    end: "18:45"
      responses:
        "200":
          description: The updated week schedule.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WeekSchedule"
              example:
                id: 60c2623b-4577-4743-84af-5cc6407b21b1
                external_uuid: 2d9f6b1e-8a3c-4e57-b0d4-6c1a9e7f2b83
                name: Office hours
                schedule:
                  tue:
                    - start: "08:30"
                      end: "12:30"
                    - start: "14:00"
                      end: "18:45"
                  thu:
                    - start: "08:30"
                      end: "12:30"
                    - start: "14:00"
                      end: "18:45"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Week schedules]
      operationId: deleteWeekSchedule
      summary: Remove a week schedule
      description: |
        Removes the schedule. It must not be in use by any credential holder or grant, or the request returns `400` with a validation error of type `in_use`.
      responses:
        "200":
          description: Schedule removed.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/holiday_schedules:
    get:
      tags: [Holiday schedules]
      operationId: listHolidaySchedules
      summary: List holiday schedules
      description: Sorted by name.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of holiday schedules.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HolidayScheduleList"
              example:
                items:
                  - id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
                    external_uuid: 9e5c1a7d-3b8f-4d26-a0e4-1f7b3c9d5a62
                    name: Company holidays 2026
                    schedule:
                      - start: "2026-11-26"
                        end: "2026-11-27"
                      - start: "2026-12-24"
                        end: "2026-12-25"
                      - start: "2026-12-31"
                        end: "2027-01-01"
                  - id: b2e8d4f6-7a1c-4e39-8d5b-0c6f2a4e8b17
                    external_uuid: null
                    name: August shutdown
                    schedule:
                      - start: "2026-08-01"
                        end: "2026-08-31"
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Holiday schedules]
      operationId: createHolidaySchedule
      summary: Create a holiday schedule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HolidayScheduleInput"
            example:
              name: Company holidays 2026
              external_uuid: 9e5c1a7d-3b8f-4d26-a0e4-1f7b3c9d5a62
              schedule:
                - start: "2026-11-26"
                  end: "2026-11-27"
                - start: "2026-12-24"
                  end: "2026-12-25"
                - start: "2026-12-31"
                  end: "2027-01-01"
      responses:
        "201":
          description: Schedule created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HolidaySchedule"
              example:
                id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
                external_uuid: 9e5c1a7d-3b8f-4d26-a0e4-1f7b3c9d5a62
                name: Company holidays 2026
                schedule:
                  - start: "2026-11-26"
                    end: "2026-11-27"
                  - start: "2026-12-24"
                    end: "2026-12-25"
                  - start: "2026-12-31"
                    end: "2027-01-01"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/holiday_schedules/{holiday_schedule_id}:
    parameters:
      - $ref: "#/components/parameters/holiday_schedule_id"
    get:
      tags: [Holiday schedules]
      operationId: getHolidaySchedule
      summary: Get a holiday schedule
      responses:
        "200":
          description: The holiday schedule.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HolidaySchedule"
              example:
                id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
                external_uuid: 9e5c1a7d-3b8f-4d26-a0e4-1f7b3c9d5a62
                name: Company holidays 2026
                schedule:
                  - start: "2026-11-26"
                    end: "2026-11-27"
                  - start: "2026-12-24"
                    end: "2026-12-25"
                  - start: "2026-12-31"
                    end: "2027-01-01"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Holiday schedules]
      operationId: updateHolidaySchedule
      summary: Update a holiday schedule
      description: |
        Changes the schedule's name or times. Fields you leave out keep their values. If you send `schedule`, it replaces the old one in full, and locks that use the schedule get the new times on their next sync.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HolidayScheduleUpdate"
            example:
              schedule:
                - start: "2026-11-26"
                  end: "2026-11-27"
                - start: "2026-12-24"
                  end: "2027-01-01"
      responses:
        "200":
          description: The updated holiday schedule.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HolidaySchedule"
              example:
                id: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
                external_uuid: 9e5c1a7d-3b8f-4d26-a0e4-1f7b3c9d5a62
                name: Company holidays 2026
                schedule:
                  - start: "2026-11-26"
                    end: "2026-11-27"
                  - start: "2026-12-24"
                    end: "2027-01-01"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Holiday schedules]
      operationId: deleteHolidaySchedule
      summary: Remove a holiday schedule
      description: |
        Removes the schedule. It must not be in use by any credential holder or grant, or the request returns `400` with a validation error of type `in_use`.
      responses:
        "200":
          description: Schedule removed.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/locks/{lock_id}/events:
    parameters:
      - $ref: "#/components/parameters/lock_id"
    get:
      tags: [Events]
      operationId: listLockEvents
      summary: List events
      description: |
        Lists the lock's events, newest recorded first. Use `has_more_items` to page back through older events. A lock that reports late can add events out of `occurred_at` order.
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of events, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EventList"
              example:
                items:
                  - id: 9729f303-b3e5-43cf-9cc6-7a88b36e9192
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    type: door_state
                    occurred_at: "2026-11-04T08:02:31-07:00"
                    action: door_opened
                  - id: 1d4d097f-c75b-420e-828d-504e903bd8f1
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    type: access
                    occurred_at: "2026-11-04T08:02:15-07:00"
                    action: unlocked
                    method: keypad
                    pin: "49820"
                    source_type: credential_holder
                    source_name: Cleaning crew
                    credential_holder_id: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
                    credential_holder_external_uuid: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
                    credential_holder_grant_id: e0d0db29-45a0-4a80-a438-9bf44e356f6a
                    credential_holder_grant_external_uuid: 15c438f9-7554-4bd1-99b3-762234a9bf27
                  - id: 301c32fd-e902-4a86-8552-a5c7ba202ac1
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    type: access
                    occurred_at: "2026-11-03T18:05:44-07:00"
                    action: locked
                    method: ble
                    source_type: user
                    source_name: Office admin
                    user_id: 5a9c2e7f-1b4d-4f86-a3e0-8d6b2c9f7e14
                  - id: a8e2c4f6-0b1d-4e3a-9c57-6d8f0a2b4c19
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    type: battery
                    occurred_at: "2026-11-03T06:00:12-07:00"
                    action: battery_low
                  - id: c6d0e2f4-8a1b-4c3d-b5e7-9f1a3c5e7d20
                    lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                    type: credential_holder_grant_sync
                    occurred_at: "2026-10-30T09:21:03-06:00"
                    action: upsert_success
                    credential_holder_id: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
                    credential_holder_external_uuid: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
                    credential_holder_grant_id: e0d0db29-45a0-4a80-a438-9bf44e356f6a
                    credential_holder_grant_external_uuid: 15c438f9-7554-4bd1-99b3-762234a9bf27
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/locks/{lock_id}/events/{event_id}:
    parameters:
      - $ref: "#/components/parameters/lock_id"
      - $ref: "#/components/parameters/event_id"
    get:
      tags: [Events]
      operationId: getLockEvent
      summary: Get an event
      responses:
        "200":
          description: The event.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Event"
              example:
                id: 301c32fd-e902-4a86-8552-a5c7ba202ac1
                lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
                type: access
                occurred_at: "2026-11-03T18:05:44-07:00"
                action: locked
                method: ble
                source_type: user
                source_name: Office admin
                user_id: 5a9c2e7f-1b4d-4f86-a3e0-8d6b2c9f7e14
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhooks
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/per_page"
      responses:
        "200":
          description: A page of webhooks.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookList"
              example:
                items:
                  - id: 2bd463b9-ef85-4349-bb51-371b32069d78
                    url: https://example.com/korelock/webhooks
                    enabled: true
                    disabled_by: null
                    secret: null
                    subscribed_events: [access]
                  - id: 853e5864-e3e7-4fff-8a1b-a0dfa549dba4
                    url: https://integrations.example.com/korelock
                    enabled: true
                    disabled_by: null
                    secret: null
                    subscribed_events: ["*"]
                metadata:
                  page: 1
                  per_page: 20
                  has_more_items: false
                  pages: 1
                  total_count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Create a webhook
      description: |
        Starts sending the subscribed event types to `url`. The URL must use HTTPS.

        Set a `secret` so you can [verify deliveries](#tag/webhooks).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookInput"
            example:
              url: https://example.com/korelock/webhooks
              enabled: true
              subscribed_events: [access]
      responses:
        "201":
          description: Webhook created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Webhook"
              example:
                id: 2bd463b9-ef85-4349-bb51-371b32069d78
                url: https://example.com/korelock/webhooks
                enabled: true
                disabled_by: null
                secret: null
                subscribed_events: [access]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/webhooks/{webhook_id}:
    parameters:
      - $ref: "#/components/parameters/webhook_id"
    get:
      tags: [Webhooks]
      operationId: getWebhook
      summary: Get a webhook
      responses:
        "200":
          description: The webhook.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Webhook"
              example:
                id: 2bd463b9-ef85-4349-bb51-371b32069d78
                url: https://example.com/korelock/webhooks
                enabled: true
                disabled_by: null
                secret: null
                subscribed_events: [access]
                created_at: "2026-10-12T17:40:03Z"
                updated_at: "2026-10-12T17:40:03Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Update a webhook
      description: Changes the webhook's URL, secret or subscribed events, or turns it on or off with `enabled`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookUpdate"
            example:
              enabled: false
      responses:
        "200":
          description: The updated webhook.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Webhook"
              example:
                id: 2bd463b9-ef85-4349-bb51-371b32069d78
                url: https://example.com/korelock/webhooks
                enabled: false
                disabled_by: user
                secret: null
                subscribed_events: [access]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook
      description: Removes the webhook and stops new deliveries to it. Deliveries already being retried may still arrive.
      responses:
        "200":
          description: Webhook deleted.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

webhooks:
  access:
    post:
      tags: [Webhooks]
      operationId: accessWebhook
      summary: "access event"
      description: |
        An access event: a lock, unlock or denied attempt. KoreLock sends this to every enabled webhook subscribed to `access` or `*`.
      parameters:
        - name: X-Hub-Signature
          in: header
          required: false
          description: "Present when the webhook has a `secret`: `sha256=` plus the HMAC-SHA256 hex digest of the body."
          schema: { type: string }
          example: sha256=3f5b0c9e1d7a2b48c6e0f9a1d3b5c7e9f2a4b6c8d0e1f3a5b7c9d1e3f5a7b9c1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AccessEvent"
            example:
              id: 1d4d097f-c75b-420e-828d-504e903bd8f1
              lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
              type: access
              occurred_at: "2026-11-04T08:02:15-07:00"
              action: unlocked
              method: keypad
              pin: "49820"
              source_type: credential_holder
              source_name: Cleaning crew
              credential_holder_id: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
              credential_holder_external_uuid: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
              credential_holder_grant_id: e0d0db29-45a0-4a80-a438-9bf44e356f6a
              credential_holder_grant_external_uuid: 15c438f9-7554-4bd1-99b3-762234a9bf27
      responses:
        "200":
          description: Return any `2xx` within 3 seconds. Other responses and timeouts are retried.
  credential_holder_grant_sync:
    post:
      tags: [Webhooks]
      operationId: credentialHolderGrantSyncWebhook
      summary: "credential_holder_grant_sync event"
      description: |
        A grant was added to or removed from a lock. KoreLock sends this to every enabled webhook subscribed to `credential_holder_grant_sync` or `*`.
      parameters:
        - name: X-Hub-Signature
          in: header
          required: false
          description: "Present when the webhook has a `secret`: `sha256=` plus the HMAC-SHA256 hex digest of the body."
          schema: { type: string }
          example: sha256=3f5b0c9e1d7a2b48c6e0f9a1d3b5c7e9f2a4b6c8d0e1f3a5b7c9d1e3f5a7b9c1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GrantSyncEvent"
            example:
              id: c6d0e2f4-8a1b-4c3d-b5e7-9f1a3c5e7d20
              lock_id: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
              type: credential_holder_grant_sync
              occurred_at: "2026-10-30T09:21:03-06:00"
              action: upsert_success
              credential_holder_id: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
              credential_holder_external_uuid: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
              credential_holder_grant_id: e0d0db29-45a0-4a80-a438-9bf44e356f6a
              credential_holder_grant_external_uuid: 15c438f9-7554-4bd1-99b3-762234a9bf27
      responses:
        "200":
          description: Return any `2xx` within 3 seconds. Other responses and timeouts are retried.

components:
  securitySchemes:
    oauth2:
      type: oauth2
      description: OAuth 2.0. Use client credentials for your own account, or the authorization code flow (with PKCE for public apps) to act for other customers.
      flows:
        clientCredentials:
          tokenUrl: https://api.korelock.com/oauth/token
          scopes:
            all: Full access to the account
        authorizationCode:
          authorizationUrl: https://korelock.webmanager.cloud/oauth/authorize
          tokenUrl: https://api.korelock.com/oauth/token
          refreshUrl: https://api.korelock.com/oauth/token
          scopes:
            all: Full access to the account
    bearerToken:
      type: http
      scheme: bearer
      description: Paste an access token you already have.

  parameters:
    page:
      name: page
      in: query
      description: Page number. Pages past the end return an empty `items` array.
      schema: { type: integer, minimum: 1, default: 1 }
    per_page:
      name: per_page
      in: query
      description: Results per page.
      schema: { type: integer, minimum: 1, maximum: 250, default: 20 }
    lock_id:
      name: lock_id
      in: path
      required: true
      description: The lock's `id`.
      schema: { type: string, format: uuid }
      example: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
    location_id:
      name: location_id
      in: path
      required: true
      description: The location's `id`, or `external_uuid:<your uuid>` to look it up by your own identifier.
      schema: { type: string }
      example: 9db51567-a8a6-4c63-b6a4-4e239b33250f
    credential_holder_id:
      name: credential_holder_id
      in: path
      required: true
      description: The credential holder's `id`, or `external_uuid:<your uuid>` to look them up by your own identifier.
      schema: { type: string }
      example: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
    grant_id:
      name: grant_id
      in: path
      required: true
      description: The grant's `id`, or `external_uuid:<your uuid>` to look it up by your own identifier.
      schema: { type: string }
      example: e0d0db29-45a0-4a80-a438-9bf44e356f6a
    smart_pin_id:
      name: smart_pin_id
      in: path
      required: true
      description: The SmartPIN's `id`.
      schema: { type: string, format: uuid }
      example: cc47b6ad-aac6-4e99-8bab-3d448f43e4e8
    action_schedule_id:
      name: action_schedule_id
      in: path
      required: true
      description: The action schedule's `id`, or `external_uuid:<your uuid>` to look it up by your own identifier.
      schema: { type: string }
      example: 93d52ee7-6fbe-4432-818f-47846fa0e21a
    auto_lock_schedule_id:
      name: auto_lock_schedule_id
      in: path
      required: true
      description: The auto-lock schedule's `id`, or `external_uuid:<your uuid>` to look it up by your own identifier.
      schema: { type: string }
      example: 3f6d2a1e-7c84-4b59-9e0a-2d5c8b71f4a6
    week_schedule_id:
      name: week_schedule_id
      in: path
      required: true
      description: The week schedule's `id`, or `external_uuid:<your uuid>` to look it up by your own identifier.
      schema: { type: string }
      example: 60c2623b-4577-4743-84af-5cc6407b21b1
    holiday_schedule_id:
      name: holiday_schedule_id
      in: path
      required: true
      description: The holiday schedule's `id`, or `external_uuid:<your uuid>` to look it up by your own identifier.
      schema: { type: string }
      example: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
    event_id:
      name: event_id
      in: path
      required: true
      description: The event's `id`.
      schema: { type: string, format: uuid }
      example: 301c32fd-e902-4a86-8552-a5c7ba202ac1
    webhook_id:
      name: webhook_id
      in: path
      required: true
      description: The webhook's `id`.
      schema: { type: string, format: uuid }
      example: 2bd463b9-ef85-4349-bb51-371b32069d78

  responses:
    BadRequest:
      description: |
        The request was invalid. `error_type` says why:

        - `validation_failed`: the data didn't pass validation. See `validation_errors`.
        - `unpermitted_params`: the body has a field this endpoint doesn't accept.
        - `invalid_parameter`: `page` or `per_page` is out of range.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ValidationError"
    Unauthorized:
      description: |
        The access token is missing, invalid or expired. For a bad token the body may be empty, with details in the `WWW-Authenticate` header. If the user or account was deleted, or the user's access has expired, the body has an `error_type` of `user_or_account_deleted` or `user_access_expired`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: No resource with that ID exists on this account.
      content:
        application/json:
          schema:
            type: object
            properties:
              status: { type: integer, example: 404 }
              error: { type: string, example: Not Found }
    RateLimited:
      description: Too many requests. Wait the number of seconds in `Retry-After`, then retry.
      headers:
        Retry-After:
          description: Seconds to wait before retrying. Always `60`.
          schema: { type: integer }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/RateLimitError"

  schemas:
    ClientCredentialsGrant:
      title: Client credentials
      type: object
      required: [grant_type, client_id, client_secret]
      properties:
        grant_type: { type: string, const: client_credentials }
        client_id: { type: string }
        client_secret: { type: string }
    AuthorizationCodeGrant:
      title: Authorization code
      type: object
      required: [grant_type, code, client_id, redirect_uri]
      properties:
        grant_type: { type: string, const: authorization_code }
        code:
          type: string
          description: The `code` from your redirect URL.
        client_id: { type: string }
        client_secret:
          type: string
          description: Confidential apps only.
        code_verifier:
          type: string
          description: Public apps only (PKCE). The verifier that produced the `code_challenge`.
        redirect_uri:
          type: string
          format: uri
          description: Must match the redirect URL registered for the app.
    RefreshTokenGrant:
      title: Refresh token
      type: object
      required: [grant_type, refresh_token, client_id]
      properties:
        grant_type: { type: string, const: refresh_token }
        refresh_token: { type: string }
        client_id: { type: string }
        client_secret:
          type: string
          description: Confidential apps only.
    Token:
      type: object
      properties:
        access_token: { type: string, example: example_access_token_2f9a6c1e8b4d }
        refresh_token:
          type: string
          description: Authorization code and refresh flows only.
          example: example_refresh_token_6b1e8d3c4a7f
        token_type: { type: string, example: Bearer }
        expires_in:
          type: integer
          description: Lifetime in seconds.
          example: 7200
        scope: { type: string, example: all }
        created_at:
          type: integer
          description: Unix timestamp. The token expires at `created_at + expires_in`.
          example: 1793900000

    PageMetadata:
      type: object
      properties:
        page: { type: integer, example: 1 }
        per_page: { type: integer, example: 20 }
        has_more_items: { type: boolean, example: false }
        pages: { type: integer, example: 1 }
        total_count: { type: integer, example: 2 }

    Location:
      type: object
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          example: 9db51567-a8a6-4c63-b6a4-4e239b33250f
        external_uuid:
          type: [string, "null"]
          format: uuid
          description: Your own identifier for this location.
          example: 08cda068-2a82-4f25-9c61-471a196dfd72
        name: { type: string, maxLength: 50, description: Unique in the account., example: Denver HQ }
        address: { type: [string, "null"], maxLength: 100, example: "1801 Wewatta St, Denver, CO" }
        timezone:
          type: string
          description: IANA time zone, such as `America/New_York`. Locks at this location use it for schedules and SmartPIN times.
          example: America/Denver
        created_at: { type: string, format: date-time, readOnly: true, example: "2026-09-02T18:20:11Z" }
        updated_at: { type: string, format: date-time, readOnly: true, example: "2026-10-28T15:01:47Z" }
    LocationInput:
      type: object
      required: [name, timezone]
      properties:
        external_uuid: { type: string, format: uuid, description: Your own identifier for this location. }
        name: { type: string, maxLength: 50, description: Unique in the account. }
        address: { type: string, maxLength: 100 }
        timezone: { type: string, description: "IANA time zone, such as `America/New_York`." }
    LocationUpdate:
      type: object
      properties:
        external_uuid: { type: string, format: uuid }
        name: { type: string }
        address: { type: string }
        timezone: { type: string }
    LocationList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Location" }
        metadata: { $ref: "#/components/schemas/PageMetadata" }

    LockModel:
      type: object
      description: A lock model and its capabilities. See [Models & capabilities](#tag/lock-models).
      properties:
        name:
          type: string
          description: Model name, including the submodel. Matches a lock's `model_code`.
          example: K52-3505
        submodel:
          type: [string, "null"]
          description: The submodel on its own. `null` if the model has no submodels. Send it as `sub_model` when you register a lock.
          example: "3505"
        submodel_number:
          type: string
          description: Numeric code of the submodel. Empty if the model has no submodels.
          example: "3505"
        pin_credential:
          type: boolean
          description: "Credential: accepts PINs (`pin`)."
        smart_card_credential:
          type: boolean
          description: "Credential: accepts cards (`card_number`). True when the model has an NFC or proximity reader."
        smart_pin_credential:
          type: boolean
          description: "Credential: accepts [SmartPINs](#tag/smartpins). Also needs the `smart_pin_enabled` setting."
        unikey_credential:
          type: boolean
          description: "Credential: accepts UniKey mobile credentials (`unikey_enabled`). Also supports the `unikey_reader` and `unikey_read_range_mode` settings."
        entry_app_credential:
          type: boolean
          description: "Credential: accepts Entry app credentials (`entry_app_enabled`)."
        nfc_reader:
          type: boolean
          description: "Reader: has an NFC card reader. Supports `nfc_antenna_enabled`."
        prox_reader:
          type: boolean
          description: "Reader: has a proximity card reader. Supports `prox_antenna_enabled`."
        unikey_reader_attached:
          type: boolean
          description: "Reader: has an attached UniKey reader. Supports `edge_controller_reader_type`."
        programming_code:
          type: boolean
          description: "Setting: supports `programming_code`."
        no_enter_code:
          type: boolean
          description: "Setting: supports `no_enter_code`."
        keypad_lockout:
          type: boolean
          description: "Setting: supports `keypad_lockout_count` and `keypad_lockout_time`."
        manual_auto_lock:
          type: boolean
          description: "Setting: supports `manual_auto_lock_timeout`."
        wake_wifi_except_manual:
          type: boolean
          description: "Setting: supports the `user_action_except_manual` value of `wake_wifi`. True for every current model."
        alarm:
          type: boolean
          description: "Setting: has an alarm. Supports `alarm_trigger_time` and `mute_alarm`."
        always_on:
          type: boolean
          description: "Setting: supports `always_on`."
        card_byte_order:
          type: boolean
          description: "Setting: supports `card_byte_order`."
        nfc_on_keypress:
          type: boolean
          description: "Setting: supports `nfc_on_keypress`. False for every current model."
        deadbolt_calibration:
          type: boolean
          description: "The model is a deadbolt that supports calibration. New locks of these models default to `auto_lock: disabled`."
        mobile_credential:
          type: boolean
          deprecated: true
          description: "Deprecated. Use `entry_app_credential`."
        prox_nfc_reader:
          type: boolean
          deprecated: true
          description: "Deprecated. Use `prox_reader`."
    Lock:
      type: object
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          example: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
        name: { type: string, example: Front door }
        serial_number: { type: string, example: K52489515403 }
        location_id: { type: string, format: uuid, example: e3aec14d-ba7f-4966-99ca-137ef6f87782 }
        model_code:
          type: string
          readOnly: true
          description: Model including submodel.
          example: K52-3505
        firmware_version: { type: [string, "null"], readOnly: true, example: nrf4.1.4+esp1.0.1 }
        mac_address: { type: [string, "null"], readOnly: true, description: Wi-Fi MAC address., example: "cf:7a:15:94:4c:7d" }
        battery_voltage:
          type: [integer, "null"]
          readOnly: true
          description: Battery voltage in hundredths of a volt. `540` means 5.4 V.
          example: 540
        secondary_wifi_enabled:
          type: boolean
          readOnly: true
          description: Whether a backup Wi-Fi network is set up. End users set this in the mobile app.
          example: true
        state:
          type: [string, "null"]
          enum: [locked, unlocked, null]
          readOnly: true
          description: The last state the lock reported. Fields the lock reports, such as `state`, `firmware_version` and `power_level`, are `null` until it first syncs.
          example: locked
        pending_state:
          type: [string, "null"]
          enum: [lock, unlock, null]
          readOnly: true
          description: A queued command waiting for the lock's next sync.
          example: null
        state_updated_at: { type: [string, "null"], format: date-time, readOnly: true, example: "2026-10-28T14:32:08Z" }
        alive:
          type: boolean
          readOnly: true
          description: Whether the lock is checking in on its expected heartbeat.
          example: true
        connected_at: { type: [string, "null"], format: date-time, readOnly: true, description: Last time the lock connected., example: "2026-10-28T15:01:47Z" }
        pending_sync: { type: boolean, readOnly: true, description: Whether changes are waiting to sync to the lock., example: false }
        power_level: { type: [integer, "null"], minimum: 1, maximum: 100, readOnly: true, description: Remaining battery percentage., example: 85 }
        wifi_level: { type: [integer, "null"], minimum: 1, maximum: 4, readOnly: true, description: Signal strength from 1 to 4., example: 4 }
        wifi_network_name: { type: [string, "null"], readOnly: true, description: Wi-Fi network the lock last joined., example: Office WiFi }
        created_at: { type: string, format: date-time, readOnly: true, example: "2026-09-02T18:20:11Z" }
        updated_at: { type: string, format: date-time, readOnly: true, example: "2026-10-28T15:01:47Z" }
    LockRegistration:
      type: object
      required: [name, serial_number, location_id]
      properties:
        name: { type: string, maxLength: 50, description: Unique within the location. }
        serial_number: { type: string }
        sub_model:
          type: string
          description: Required for model series with submodels. See the table above.
        location_id: { type: string, format: uuid }
    LockUpdate:
      type: object
      properties:
        name: { type: string }
        location_id: { type: string, format: uuid }
    LockList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Lock" }
        metadata: { $ref: "#/components/schemas/PageMetadata" }
    LockCommandResult:
      type: object
      properties:
        state:
          type: [string, "null"]
          enum: [locked, unlocked, null]
          description: The last state the lock reported.
        pending_state:
          type: [string, "null"]
          enum: [lock, unlock, null]
          description: The queued command, if any.

    LockSettings:
      type: object
      description: |
        A lock's settings. Not every model supports every setting; settings the model doesn't support are left out. Check the [model capabilities](#tag/lock-models) to see which apply.
      properties:
        action_schedule_id:
          type: [string, "null"]
          format: uuid
          description: An [action schedule](#tag/action-schedules) that limits when access is allowed. `null` means no schedule.
          default: null
          example: null
        alarm_trigger_time:
          type: [integer, "null"]
          minimum: 10
          maximum: 120
          default: 20
          description: Seconds before the alarm sounds when `mute_alarm` is `false`. Models with the `alarm` capability only.
          example: 20
        always_on:
          type: [boolean, "null"]
          default: false
          description: Keeps the lock's connection to the cloud open all the time. Models with the `always_on` capability only.
          example: false
        auto_lock:
          type: string
          enum: [enabled, disabled, schedule]
          default: enabled
          description: |
            When the lock locks itself, `auto_lock_timeout` seconds after it unlocks:

            - `enabled`: always.
            - `disabled`: never.
            - `schedule`: follows the auto-lock schedule in `auto_lock_schedule_id`.

            Defaults to `disabled` on deadbolt models.
          example: enabled
        auto_lock_schedule_id:
          type: [string, "null"]
          format: uuid
          default: null
          description: The [auto-lock schedule](#tag/auto-lock-schedules) to follow when `auto_lock` is `schedule`.
          example: null
        auto_lock_timeout:
          type: integer
          minimum: 5
          maximum: 1800
          default: 5
          description: Seconds after unlocking before the lock auto-locks.
          example: 30
        card_byte_order:
          type: [string, "null"]
          enum: [least_significant_byte, most_significant_byte, null]
          description: The byte order used to read cards. Defaults to `least_significant_byte`. Models with the `card_byte_order` capability only.
          example: null
        edge_controller_reader_type:
          type: [string, "null"]
          enum: [unikey, card_only, card_and_pin, null]
          description: |
            The type of reader attached to the edge controller. Models with the `unikey_reader_attached` capability only.

            - `unikey`: UniKey credential reader.
            - `card_only`: card reader.
            - `card_and_pin`: card and PIN reader.
          example: null
        heartbeat_interval:
          type: integer
          default: 60
          description: Minutes between heartbeat check-ins. Use a value from 5 to 120, or a whole number of hours from 180 to 1440 (180, 240, 300 and so on, up to 24 hours).
          minimum: 5
          maximum: 1440
          example: 60
        keypad_lockout_count:
          type: [integer, "null"]
          # TODO(confirm): Whether 0 also turns lockout off (firmware behaviour).
          description: Failed keypad attempts before the keypad locks out. Not set (`null`) by default, which leaves lockout off. Models with the `keypad_lockout` capability only.
          example: 4
        keypad_lockout_time:
          type: [integer, "null"]
          description: Seconds the keypad stays locked out. Not set (`null`) by default. Needs `keypad_lockout_count` above 0. Models with the `keypad_lockout` capability only.
          example: 30
        manual_auto_lock_timeout:
          type: [integer, "null"]
          minimum: 0
          description: Seconds before the lock auto-locks after a manual opening, such as turning the knob. Doesn't apply to keypad or remote unlocks. `null` (the default) or `0` turns it off. Models with the `manual_auto_lock` capability only.
          example: 0
        mute:
          type: boolean
          default: false
          description: Turns the lock's sounds off.
          example: false
        mute_alarm:
          type: [boolean, "null"]
          default: false
          description: Turns the alarm off. Models with the `alarm` capability only.
          example: false
        nfc:
          type: [string, "null"]
          enum: ["off", always_on, on_keypress, null]
          deprecated: true
          description: Deprecated. Use `nfc_antenna_enabled` and `nfc_on_keypress` instead. Worked out from those two settings; returned only on NFC models that aren't PDQ locks. Sending a value that disagrees with them in the same request returns `400` (`conflicting_reader_settings`).
          example: on_keypress
        nfc_antenna_enabled:
          type: [boolean, "null"]
          default: false
          description: Turns the NFC card reader on. Defaults to `true` on PDQ locks. Models with the `nfc_reader` capability only.
          example: true
        nfc_on_keypress:
          type: [boolean, "null"]
          default: false
          description: Turns the NFC reader on only after a key press, which saves battery. Models with the `nfc_on_keypress` capability only; no current model has it, so only `false` is accepted.
          example: true
        no_enter_code:
          type: [boolean, "null"]
          description: Lets users skip the `#` key after entering a PIN. Not set (`null`) by default. Models with the `no_enter_code` capability only.
          example: false
        online_auto_lock:
          type: [boolean, "null"]
          description: Whether the lock auto-locks after a remote unlock from the API, web app or mobile app. Not set (`null`) by default.
          example: true
        programming_code:
          type: [string, "null"]
          pattern: "^[0-9]{4,10}$"
          default: "123456"
          description: Gives admin access to the lock from the keypad. 4 to 10 digits; leading zeros are allowed. Change it from the default. It can't start with a PIN that's already on the lock (`pin_credential_collision`). Models with the `programming_code` capability only.
          example: "482915"
        prox_antenna_enabled:
          type: [boolean, "null"]
          default: false
          description: Turns the proximity (prox) card reader on. Models with the `prox_reader` capability only.
          example: false
        reader_frequency_mode:
          type: [string, "null"]
          enum: [both_off, nfc_only, prox_only, both_on, null]
          deprecated: true
          description: Deprecated. Use `nfc_antenna_enabled` and `prox_antenna_enabled` instead. Which card reader frequencies are on. PDQ locks only; ignored on other locks. Sending a value that disagrees with the newer settings in the same request returns `400` (`conflicting_reader_settings`).
          example: null
        smart_pin_enabled:
          type: [boolean, "null"]
          default: false
          description: Turns [SmartPINs](#tag/smartpins) on. Only has an effect on models with the `smart_pin_credential` capability.
          example: false
        smart_pin_seed:
          type: string
          pattern: "^[0-9]{4,10}$"
          default: "0000"
          description: |
            The seed used to generate SmartPINs. `0000` uses the lock's serial number, so each lock works on its own.

            Give several locks the same seed (4 to 10 digits) to let one SmartPIN open all of them during the same time window.
          example: "0000"
        unikey_read_range:
          type: integer
          deprecated: true
          minimum: -80
          maximum: -30
          description: Deprecated. Use `unikey_read_range_mode`. The read range as a signal strength. If you send both, `unikey_read_range_mode` wins.
          example: -50
        unikey_read_range_mode:
          type: string
          enum: [close, intermediate, far, unknown]
          default: intermediate
          description: How far away the UniKey reader detects credentials. Models with the `unikey_credential` capability only. A response may show `unknown`; sending `unknown` is ignored.
          example: null
        unikey_reader:
          type: [string, "null"]
          enum: [disabled, trigger_button_press, trigger_proximity_sensor, trigger_all]
          default: disabled
          description: What triggers the UniKey reader. Models with the `unikey_credential` capability only; `trigger_proximity_sensor` works on KCM and PDQ models only.
          example: disabled
        wake_wifi:
          type: string
          enum: [user_action, heartbeat_interval, user_action_except_manual]
          default: user_action
          description: |
            When the lock connects to Wi-Fi to sync:

            - `user_action`: when someone uses the lock, such as a key press or unlock.
            - `heartbeat_interval`: only on each heartbeat.
            - `user_action_except_manual`: like `user_action`, but not when the knob is turned by hand. Every current model supports it.
          example: user_action
        wifi_enabled:
          type: boolean
          default: true
          description: Turns the lock's Wi-Fi on or off.
          example: true
        created_at: { type: string, format: date-time, readOnly: true, description: When the lock was registered., example: "2026-09-02T18:20:11Z" }
        updated_at: { type: string, format: date-time, readOnly: true, description: When the lock record last changed., example: "2026-10-28T15:01:47Z" }

    CredentialHolder:
      type: object
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          example: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
        external_uuid:
          type: [string, "null"]
          format: uuid
          description: Your own identifier for this credential holder. Must be unique in the account.
          example: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
        name:
          type: string
          description: Must be unique in the account.
          example: Cleaning crew
        email:
          type: [string, "null"]
          format: email
          description: Required when `entry_app_enabled` or `unikey_enabled` is `true`.
          example: cleaning@example.com
        pin:
          type: [string, "null"]
          pattern: "^[0-9]{4,10}$"
          description: |
            A keypad PIN of 4 to 10 digits. Must be unique in the account. It can't be one digit repeated (such as `4444`) or a run of counting digits up or down (such as `5678` or `8765`).
          example: "49820"
        card_number:
          type: [string, "null"]
          pattern: "^[0-9]{4,20}$"
          description: A card number of 4 to 20 digits. Must be unique in the account.
          example: "4100094630"
        unikey_card_number:
          type: [integer, "null"]
          minimum: 1
          maximum: 65535
          description: A UniKey card number. Must be unique in the account.
          example: null
        entry_app_enabled:
          type: boolean
          description: |
            Emails the person an invitation to the Entry app. The app downloads a credential for each of their locks whose model has `entry_app_credential`, which opens the lock even when it's offline. Needs `email`, and the Entry app feature turned on for your account.

            When you remove or reduce someone's access, the app drops or replaces their credentials the next time it refreshes, which it does each time it's opened. If it can't refresh, for example because the phone is deliberately kept offline, their current credentials keep working until 7 days after they were downloaded.
          example: false
        entry_app_pin_enabled:
          type: boolean
          description: |
            Lets the person set a PIN on each lock from the Entry app, to open it from the keypad without their phone. Can only be `true` when `entry_app_enabled` is `true`.
          example: false
        unikey_enabled:
          type: boolean
          description: |
            Creates a UniKey mobile credential and emails the person an invitation to download the UniKey app with the credential built in. Needs:

            - `email`.
            - UniKey mobile credentials turned on for your account.

            The lock's [`unikey_reader` setting](#tag/settings) must also be set to something other than `disabled` for the credential to work.

            Setting it to `false` revokes the credential on the UniKey servers.
          example: true
        week_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [week schedule](#tag/week-schedules) that limits when this person has access.
          example: 60c2623b-4577-4743-84af-5cc6407b21b1
        holiday_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [holiday schedule](#tag/holiday-schedules) that limits access on holidays.
          example: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
        start_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access starts, as `YYYY-MM-DDTHH:MM` local time at each lock, with no time zone. Set it with `end_time`.
          example: "2026-11-01T08:30"
        end_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access ends, as `YYYY-MM-DDTHH:MM` with no time zone. Must be after `start_time`.
          example: "2026-12-31T22:00"
        total_pending:
          type: integer
          readOnly: true
          description: Grants waiting to sync to their lock.
          example: 0
        total_failed:
          type: integer
          readOnly: true
          description: Grants that failed to sync.
          example: 0
        total_synced:
          type: integer
          readOnly: true
          description: Grants synced to their lock.
          example: 2
        created_at: { type: string, format: date-time, readOnly: true, example: "2026-10-30T16:05:12Z" }
        updated_at: { type: string, format: date-time, readOnly: true, example: "2026-10-30T16:20:44Z" }
    CredentialHolderWithGrants:
      allOf:
        - $ref: "#/components/schemas/CredentialHolder"
        - type: object
          properties:
            grants:
              type: array
              description: The grants created with the credential holder.
              items: { $ref: "#/components/schemas/Grant" }
    CredentialHolderCreate:
      type: object
      description: |
        Needs `name` and at least one credential: `pin`, `card_number`, `unikey_card_number`, `entry_app_enabled` or `unikey_enabled`.
      required: [name]
      properties:
        external_uuid:
          type: [string, "null"]
          format: uuid
          description: Your own identifier for this credential holder. Must be unique in the account.
          example: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
        name:
          type: string
          description: Must be unique in the account.
          example: Cleaning crew
        email:
          type: [string, "null"]
          format: email
          description: Required when `entry_app_enabled` or `unikey_enabled` is `true`.
          example: cleaning@example.com
        pin:
          type: [string, "null"]
          pattern: "^[0-9]{4,10}$"
          description: |
            A keypad PIN of 4 to 10 digits. Must be unique in the account. It can't be one digit repeated (such as `4444`) or a run of counting digits up or down (such as `5678` or `8765`).
          example: "49820"
        card_number:
          type: [string, "null"]
          pattern: "^[0-9]{4,20}$"
          description: A card number of 4 to 20 digits. Must be unique in the account.
          example: "4100094630"
        unikey_card_number:
          type: [integer, "null"]
          minimum: 1
          maximum: 65535
          description: A UniKey card number. Must be unique in the account.
          example: null
        entry_app_enabled:
          type: boolean
          description: |
            Emails the person an invitation to the Entry app. The app downloads a credential for each of their locks whose model has `entry_app_credential`, which opens the lock even when it's offline. Needs `email`, and the Entry app feature turned on for your account.

            When you remove or reduce someone's access, the app drops or replaces their credentials the next time it refreshes, which it does each time it's opened. If it can't refresh, for example because the phone is deliberately kept offline, their current credentials keep working until 7 days after they were downloaded.
          example: false
        entry_app_pin_enabled:
          type: boolean
          description: |
            Lets the person set a PIN on each lock from the Entry app, to open it from the keypad without their phone. Can only be `true` when `entry_app_enabled` is `true`.
          example: false
        unikey_enabled:
          type: boolean
          description: |
            Creates a UniKey mobile credential and emails the person an invitation to download the UniKey app with the credential built in. Needs:

            - `email`.
            - UniKey mobile credentials turned on for your account.

            The lock's [`unikey_reader` setting](#tag/settings) must also be set to something other than `disabled` for the credential to work.

            Setting it to `false` revokes the credential on the UniKey servers.
          example: true
        week_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [week schedule](#tag/week-schedules) that limits when this person has access.
          example: 60c2623b-4577-4743-84af-5cc6407b21b1
        holiday_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [holiday schedule](#tag/holiday-schedules) that limits access on holidays.
          example: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
        start_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access starts, as `YYYY-MM-DDTHH:MM` local time at each lock, with no time zone. Set it with `end_time`.
          example: "2026-11-01T08:30"
        end_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access ends, as `YYYY-MM-DDTHH:MM` with no time zone. Must be after `start_time`.
          example: "2026-12-31T22:00"
        unikey_installer:
          type: boolean
          description: Makes the UniKey credential an installer credential, which can also pair, configure and update the firmware of UniKey readers. Only accepted when creating a credential holder, and only when `unikey_enabled` is `true`.
          example: false
        grants:
          type: array
          description: Locks to give access to. Each grant starts as `pending` until its lock syncs.
          items:
            type: object
            required: [lock_id]
            properties:
              lock_id:
                type: string
                format: uuid
                example: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
    CredentialHolderUpdate:
      type: object
      description: Grants can't be changed here, and `unikey_installer` is only accepted on create.
      properties:
        external_uuid:
          type: [string, "null"]
          format: uuid
          description: Your own identifier for this credential holder. Must be unique in the account.
          example: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
        name:
          type: string
          description: Must be unique in the account.
          example: Cleaning crew
        email:
          type: [string, "null"]
          format: email
          description: Required when `entry_app_enabled` or `unikey_enabled` is `true`.
          example: cleaning@example.com
        pin:
          type: [string, "null"]
          pattern: "^[0-9]{4,10}$"
          description: |
            A keypad PIN of 4 to 10 digits. Must be unique in the account. It can't be one digit repeated (such as `4444`) or a run of counting digits up or down (such as `5678` or `8765`).
          example: "49820"
        card_number:
          type: [string, "null"]
          pattern: "^[0-9]{4,20}$"
          description: A card number of 4 to 20 digits. Must be unique in the account.
          example: "4100094630"
        unikey_card_number:
          type: [integer, "null"]
          minimum: 1
          maximum: 65535
          description: A UniKey card number. Must be unique in the account.
          example: null
        entry_app_enabled:
          type: boolean
          description: |
            Emails the person an invitation to the Entry app. The app downloads a credential for each of their locks whose model has `entry_app_credential`, which opens the lock even when it's offline. Needs `email`, and the Entry app feature turned on for your account.

            When you remove or reduce someone's access, the app drops or replaces their credentials the next time it refreshes, which it does each time it's opened. If it can't refresh, for example because the phone is deliberately kept offline, their current credentials keep working until 7 days after they were downloaded.
          example: false
        entry_app_pin_enabled:
          type: boolean
          description: |
            Lets the person set a PIN on each lock from the Entry app, to open it from the keypad without their phone. Can only be `true` when `entry_app_enabled` is `true`.
          example: false
        unikey_enabled:
          type: boolean
          description: |
            Creates a UniKey mobile credential and emails the person an invitation to download the UniKey app with the credential built in. Needs:

            - `email`.
            - UniKey mobile credentials turned on for your account.

            The lock's [`unikey_reader` setting](#tag/settings) must also be set to something other than `disabled` for the credential to work.

            Setting it to `false` revokes the credential on the UniKey servers.
          example: true
        week_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [week schedule](#tag/week-schedules) that limits when this person has access.
          example: 60c2623b-4577-4743-84af-5cc6407b21b1
        holiday_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [holiday schedule](#tag/holiday-schedules) that limits access on holidays.
          example: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
        start_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access starts, as `YYYY-MM-DDTHH:MM` local time at each lock, with no time zone. Set it with `end_time`.
          example: "2026-11-01T08:30"
        end_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access ends, as `YYYY-MM-DDTHH:MM` with no time zone. Must be after `start_time`.
          example: "2026-12-31T22:00"
    CredentialHolderList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/CredentialHolder" }
        metadata: { $ref: "#/components/schemas/PageMetadata" }

    Grant:
      type: object
      description: Access for one credential holder to one lock.
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          example: e0d0db29-45a0-4a80-a438-9bf44e356f6a
        external_uuid:
          type: [string, "null"]
          format: uuid
          description: Your own identifier for this grant.
          example: 15c438f9-7554-4bd1-99b3-762234a9bf27
        lock_id:
          type: string
          format: uuid
          description: The lock this grant opens. Can't be changed.
          example: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
        week_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [week schedule](#tag/week-schedules) for this lock only. Overrides the credential holder's; `null` uses theirs.
          example: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
        holiday_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [holiday schedule](#tag/holiday-schedules) for this lock only. Overrides the credential holder's; `null` uses theirs.
          example: null
        start_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access to this lock starts, as `YYYY-MM-DDTHH:MM`. Overrides the credential holder's. Set it with `end_time`.
          example: "2026-11-01T08:30"
        end_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access to this lock ends, as `YYYY-MM-DDTHH:MM`. Overrides the credential holder's. Must be after `start_time`.
          example: "2026-12-31T22:00"
        sync_status:
          type: string
          enum: [pending, synced, failed]
          readOnly: true
          description: Whether the grant has reached its lock. `pending` until the lock next syncs.
          example: pending
        expired:
          type: boolean
          readOnly: true
          description: Whether the grant's `end_time` (or the credential holder's, if the grant has none) has passed, in the lock's local time.
          example: false
        created_at: { type: string, format: date-time, readOnly: true, example: "2026-10-30T16:05:12Z" }
        updated_at: { type: string, format: date-time, readOnly: true, example: "2026-10-30T16:05:12Z" }
    GrantWithLock:
      allOf:
        - $ref: "#/components/schemas/Grant"
        - type: object
          properties:
            lock:
              $ref: "#/components/schemas/Lock"
              readOnly: true
              description: The lock this grant opens.
    GrantWithCredentialHolder:
      allOf:
        - $ref: "#/components/schemas/Grant"
        - type: object
          properties:
            credential_holder:
              $ref: "#/components/schemas/CredentialHolder"
              readOnly: true
              description: The credential holder this grant belongs to.
    GrantCreate:
      type: object
      required: [lock_id]
      properties:
        external_uuid:
          type: string
          format: uuid
          description: Your own identifier for this grant.
        lock_id:
          type: string
          format: uuid
          description: A lock in your account. A credential holder can have only one grant per lock.
        week_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [week schedule](#tag/week-schedules) for this lock only. Overrides the credential holder's; `null` uses theirs.
          example: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
        holiday_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [holiday schedule](#tag/holiday-schedules) for this lock only. Overrides the credential holder's; `null` uses theirs.
          example: null
        start_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access to this lock starts, as `YYYY-MM-DDTHH:MM`. Overrides the credential holder's. Set it with `end_time`.
          example: "2026-11-01T08:30"
        end_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access to this lock ends, as `YYYY-MM-DDTHH:MM`. Overrides the credential holder's. Must be after `start_time`.
          example: "2026-12-31T22:00"
    GrantUpdate:
      type: object
      description: "Send every override you want to keep: fields you leave out are cleared. `lock_id` and `external_uuid` can't be changed."
      properties:
        week_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [week schedule](#tag/week-schedules) for this lock only. Overrides the credential holder's; `null` uses theirs.
          example: 8d1f3c6a-2e7b-4a95-b0c4-5f9e1d7a3b62
        holiday_schedule_id:
          type: [string, "null"]
          format: uuid
          description: A [holiday schedule](#tag/holiday-schedules) for this lock only. Overrides the credential holder's; `null` uses theirs.
          example: null
        start_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access to this lock starts, as `YYYY-MM-DDTHH:MM`. Overrides the credential holder's. Set it with `end_time`.
          example: "2026-11-01T08:30"
        end_time:
          type: [string, "null"]
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
          description: When access to this lock ends, as `YYYY-MM-DDTHH:MM`. Overrides the credential holder's. Must be after `start_time`.
          example: "2026-12-31T22:00"
    CredentialHolderGrantList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/GrantWithLock" }
        metadata: { $ref: "#/components/schemas/PageMetadata" }
    LockGrantList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/GrantWithCredentialHolder" }
        metadata: { $ref: "#/components/schemas/PageMetadata" }
    SmartPin:
      type: object
      description: A time-limited guest PIN that works without a network connection.
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          example: cc47b6ad-aac6-4e99-8bab-3d448f43e4e8
        lock_id:
          type: string
          format: uuid
          readOnly: true
          example: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
        name:
          type: string
          description: A label for the guest or purpose.
          example: Guest, unit 204
        email:
          type: [string, "null"]
          format: email
          example: guest204@example.com
        pin:
          type: string
          readOnly: true
          pattern: "^[0-9]{10}$"
          description: The generated 10-digit PIN to give the guest.
          example: "4418850246"
        start_time:
          type: string
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:00$"
          description: When the SmartPIN starts working, as `YYYY-MM-DDTHH:00`. Can't be changed.
          example: "2026-11-14T15:00"
        end_time:
          type: string
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:00$"
          description: When the SmartPIN stops working, as `YYYY-MM-DDTHH:00`. Can't be changed.
          example: "2026-11-21T11:00"
        expired:
          type: boolean
          readOnly: true
          description: Whether the SmartPIN has expired, in the lock's local time.
          example: false
        type:
          type: string
          const: smart_pin_credential
          readOnly: true
          example: smart_pin_credential
        sync_status:
          type: string
          const: ready
          readOnly: true
          description: Always `ready`. SmartPINs don't need to sync to the lock.
          example: ready
        created_at: { type: string, format: date-time, readOnly: true, example: "2026-11-05T09:14:22Z" }
        updated_at: { type: string, format: date-time, readOnly: true, example: "2026-11-05T09:14:22Z" }
    SmartPinCreate:
      type: object
      required: [name, start_time, end_time]
      properties:
        name:
          type: string
          description: A label for the guest or purpose.
        email:
          type: string
          format: email
        start_time:
          type: string
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:00$"
          description: A whole hour, as `YYYY-MM-DDTHH:00`. Must be `08:00` for long-term SmartPINs.
        end_time:
          type: string
          pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:00$"
          description: A whole hour, as `YYYY-MM-DDTHH:00`. Up to 32 days after `start_time` (any hour), or up to a year if it ends at `20:00`.
    SmartPinUpdate:
      type: object
      description: Only `name` and `email` can be changed.
      properties:
        name: { type: string }
        email: { type: string, format: email }
    SmartPinList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/SmartPin" }
        metadata: { $ref: "#/components/schemas/PageMetadata" }
    ActionSchedule:
      type: object
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          example: 93d52ee7-6fbe-4432-818f-47846fa0e21a
        external_uuid:
          type: [string, "null"]
          format: uuid
          description: Your own identifier for this schedule.
          example: 522a9219-d6ea-4f92-ae7a-e1f8e3eae053
        account_id:
          type: string
          format: uuid
          readOnly: true
          description: The account that owns the schedule.
          example: f7c03f5d-625a-45f4-abe5-d755553bd370
        name:
          type: string
          description: Must be unique in the account.
          example: Lobby open hours
        schedule:
          description: Day-by-day list of lock and unlock times. See [schedule format](#tag/action-schedules).
          allOf:
            - { $ref: "#/components/schemas/ActionScheduleDays" }
        created_at: { type: string, format: date-time, readOnly: true, example: "2026-10-20T15:12:40Z" }
        updated_at: { type: string, format: date-time, readOnly: true, example: "2026-10-20T15:12:40Z" }
    ActionScheduleInput:
      type: object
      required: [name, schedule]
      properties:
        external_uuid: { type: string, format: uuid, description: Your own identifier for this schedule. }
        name: { type: string, description: Must be unique among the account's schedules of this kind. }
        schedule: { $ref: "#/components/schemas/ActionScheduleDays" }
    ActionScheduleUpdate:
      type: object
      properties:
        external_uuid: { type: string, format: uuid }
        name: { type: string, description: Must be unique among the account's schedules of this kind. }
        schedule: { $ref: "#/components/schemas/ActionScheduleDays" }
    ActionScheduleList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/ActionSchedule" }
        metadata: { $ref: "#/components/schemas/PageMetadata" }
    ClockTime:
      type: string
      pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$"
      description: 24-hour time, `HH:MM`, in the lock's local time (its location's time zone).
      example: "09:00"
    ActionScheduleEntry:
      description: One timed action. An object with a single key.
      oneOf:
        - title: unlock
          type: object
          required: [unlock]
          additionalProperties: false
          properties:
            unlock:
              $ref: "#/components/schemas/ClockTime"
              description: Unlock at this time.
        - title: lock
          type: object
          required: [lock]
          additionalProperties: false
          properties:
            lock:
              $ref: "#/components/schemas/ClockTime"
              description: Lock at this time.
    ActionScheduleDays:
      type: object
      description: Up to 10 entries per day, in any order. Leave out days with nothing scheduled. Don't send a day with an empty list.
      additionalProperties: false
      minProperties: 1
      properties:
        mon:
          type: array
          description: Monday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/ActionScheduleEntry" }
        tue:
          type: array
          description: Tuesday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/ActionScheduleEntry" }
        wed:
          type: array
          description: Wednesday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/ActionScheduleEntry" }
        thu:
          type: array
          description: Thursday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/ActionScheduleEntry" }
        fri:
          type: array
          description: Friday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/ActionScheduleEntry" }
        sat:
          type: array
          description: Saturday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/ActionScheduleEntry" }
        sun:
          type: array
          description: Sunday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/ActionScheduleEntry" }
    AutoLockSchedule:
      type: object
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          example: 3f6d2a1e-7c84-4b59-9e0a-2d5c8b71f4a6
        external_uuid:
          type: [string, "null"]
          format: uuid
          description: Your own identifier for this schedule.
          example: 0b8e4c2a-6f1d-4e93-a7b5-c2d9e0f13a64
        account_id:
          type: string
          format: uuid
          readOnly: true
          description: The account that owns the schedule.
          example: f7c03f5d-625a-45f4-abe5-d755553bd370
        name:
          type: string
          description: Must be unique in the account.
          example: After hours
        schedule:
          description: Day-by-day list of auto-lock on and off times. See [schedule format](#tag/action-schedules).
          allOf:
            - { $ref: "#/components/schemas/AutoLockScheduleDays" }
        created_at: { type: string, format: date-time, readOnly: true, example: "2026-10-20T15:12:40Z" }
        updated_at: { type: string, format: date-time, readOnly: true, example: "2026-10-20T15:12:40Z" }
    AutoLockScheduleInput:
      type: object
      required: [name, schedule]
      properties:
        external_uuid: { type: string, format: uuid, description: Your own identifier for this schedule. }
        name: { type: string, description: Must be unique among the account's schedules of this kind. }
        schedule: { $ref: "#/components/schemas/AutoLockScheduleDays" }
    AutoLockScheduleUpdate:
      type: object
      properties:
        external_uuid: { type: string, format: uuid }
        name: { type: string, description: Must be unique among the account's schedules of this kind. }
        schedule: { $ref: "#/components/schemas/AutoLockScheduleDays" }
    AutoLockScheduleList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/AutoLockSchedule" }
        metadata: { $ref: "#/components/schemas/PageMetadata" }
    AutoLockScheduleEntry:
      description: One timed action. An object with a single key.
      oneOf:
        - title: auto-lock-on
          type: object
          required: [auto-lock-on]
          additionalProperties: false
          properties:
            auto-lock-on:
              $ref: "#/components/schemas/ClockTime"
              description: Turn auto-lock on at this time.
        - title: auto-lock-off
          type: object
          required: [auto-lock-off]
          additionalProperties: false
          properties:
            auto-lock-off:
              $ref: "#/components/schemas/ClockTime"
              description: Turn auto-lock off at this time.
    AutoLockScheduleDays:
      type: object
      description: Up to 10 entries per day, in any order. Leave out days with nothing scheduled. Don't send a day with an empty list.
      additionalProperties: false
      minProperties: 1
      properties:
        mon:
          type: array
          description: Monday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/AutoLockScheduleEntry" }
        tue:
          type: array
          description: Tuesday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/AutoLockScheduleEntry" }
        wed:
          type: array
          description: Wednesday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/AutoLockScheduleEntry" }
        thu:
          type: array
          description: Thursday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/AutoLockScheduleEntry" }
        fri:
          type: array
          description: Friday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/AutoLockScheduleEntry" }
        sat:
          type: array
          description: Saturday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/AutoLockScheduleEntry" }
        sun:
          type: array
          description: Sunday.
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/AutoLockScheduleEntry" }
    WeekSchedule:
      type: object
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          example: 60c2623b-4577-4743-84af-5cc6407b21b1
        external_uuid:
          type: [string, "null"]
          format: uuid
          description: Your own identifier for this schedule.
          example: 2d9f6b1e-8a3c-4e57-b0d4-6c1a9e7f2b83
        name:
          type: string
          description: Must be unique in the account.
          example: Office hours
        schedule:
          description: Day-by-day list of time intervals when access is allowed. See [schedule format](#tag/action-schedules).
          allOf:
            - { $ref: "#/components/schemas/WeekScheduleDays" }
        created_at: { type: string, format: date-time, readOnly: true, example: "2026-10-20T15:12:40Z" }
        updated_at: { type: string, format: date-time, readOnly: true, example: "2026-10-20T15:12:40Z" }
    WeekScheduleInput:
      type: object
      required: [name, schedule]
      properties:
        external_uuid: { type: string, format: uuid, description: Your own identifier for this schedule. }
        name: { type: string, description: Must be unique among the account's schedules of this kind. }
        schedule: { $ref: "#/components/schemas/WeekScheduleDays" }
    WeekScheduleUpdate:
      type: object
      properties:
        external_uuid: { type: string, format: uuid }
        name: { type: string, description: Must be unique among the account's schedules of this kind. }
        schedule: { $ref: "#/components/schemas/WeekScheduleDays" }
    WeekScheduleList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/WeekSchedule" }
        metadata: { $ref: "#/components/schemas/PageMetadata" }
    TimeInterval:
      type: object
      required: [start, end]
      properties:
        start: { $ref: "#/components/schemas/ClockTime" }
        end: { $ref: "#/components/schemas/ClockTime" }
    WeekScheduleDays:
      type: object
      description: Time intervals when access is allowed, by day. Leave out days with no access. Don't send a day with an empty list.
      additionalProperties: false
      minProperties: 1
      properties:
        mon:
          type: array
          description: Monday.
          minItems: 1
          items: { $ref: "#/components/schemas/TimeInterval" }
        tue:
          type: array
          description: Tuesday.
          minItems: 1
          items: { $ref: "#/components/schemas/TimeInterval" }
        wed:
          type: array
          description: Wednesday.
          minItems: 1
          items: { $ref: "#/components/schemas/TimeInterval" }
        thu:
          type: array
          description: Thursday.
          minItems: 1
          items: { $ref: "#/components/schemas/TimeInterval" }
        fri:
          type: array
          description: Friday.
          minItems: 1
          items: { $ref: "#/components/schemas/TimeInterval" }
        sat:
          type: array
          description: Saturday.
          minItems: 1
          items: { $ref: "#/components/schemas/TimeInterval" }
        sun:
          type: array
          description: Sunday.
          minItems: 1
          items: { $ref: "#/components/schemas/TimeInterval" }
    HolidaySchedule:
      type: object
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          example: 4b3aa724-e570-4e7d-9d75-e7cfd5a5e079
        external_uuid:
          type: [string, "null"]
          format: uuid
          description: Your own identifier for this schedule.
          example: 9e5c1a7d-3b8f-4d26-a0e4-1f7b3c9d5a62
        name:
          type: string
          description: Must be unique in the account.
          example: Company holidays 2026
        schedule:
          description: List of date ranges when access is blocked.
          allOf:
            - { type: array, minItems: 1, items: { $ref: "#/components/schemas/DateRange" } }
        created_at: { type: string, format: date-time, readOnly: true, example: "2026-10-20T15:12:40Z" }
        updated_at: { type: string, format: date-time, readOnly: true, example: "2026-10-20T15:12:40Z" }
    HolidayScheduleInput:
      type: object
      required: [name, schedule]
      properties:
        external_uuid: { type: string, format: uuid, description: Your own identifier for this schedule. }
        name: { type: string, description: Must be unique among the account's schedules of this kind. }
        schedule: { type: array, minItems: 1, items: { $ref: "#/components/schemas/DateRange" } }
    HolidayScheduleUpdate:
      type: object
      properties:
        external_uuid: { type: string, format: uuid }
        name: { type: string, description: Must be unique among the account's schedules of this kind. }
        schedule: { type: array, minItems: 1, items: { $ref: "#/components/schemas/DateRange" } }
    HolidayScheduleList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/HolidaySchedule" }
        metadata: { $ref: "#/components/schemas/PageMetadata" }
    DateRange:
      type: object
      required: [start, end]
      description: A range of dates. Both `start` and `end` are included, and `start` can't be after `end`.
      properties:
        start: { type: string, format: date, example: "2026-12-24" }
        end: { type: string, format: date, example: "2026-12-25" }
    EventBase:
      type: object
      required: [id, lock_id, type, occurred_at, action]
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          example: 1d4d097f-c75b-420e-828d-504e903bd8f1
        lock_id:
          type: string
          format: uuid
          description: The lock where the event happened.
          example: fbae92f9-3bb9-4ad1-b86e-6b802200f1fe
        type:
          type: string
          enum: [access, credential_holder_grant_sync, battery, door_state, reset, local_pin_sync, reboot]
          description: The event's category.
          example: access
        occurred_at:
          type: string
          format: date-time
          description: When it happened, with the lock's local UTC offset.
          example: "2026-11-04T08:02:15-07:00"
        action:
          type: string
          description: What happened. The values depend on `type`.
          example: unlocked
        created_at: { type: string, format: date-time, readOnly: true, example: "2026-11-04T15:02:20Z" }
        updated_at: { type: string, format: date-time, readOnly: true, example: "2026-11-04T15:02:20Z" }
    LegacyEventFields:
      type: object
      description: Fields returned on every event type except `access` and `credential_holder_grant_sync`.
      properties:
        method:
          type: [string, "null"]
          description: How the event was triggered, when known. `keypad` for local PIN changes, and `keypad` or `ble` for resets. Otherwise `null`.
          example: null
        source_entity_type:
          type: [string, "null"]
          deprecated: true
          description: Deprecated. Who or what caused the event, when known.
          example: null
        source_entity_name:
          type: [string, "null"]
          deprecated: true
          description: Deprecated. Who or what caused the event, when known.
          example: null
    Event:
      description: A lock event. The fields depend on `type`.
      oneOf:
        - $ref: "#/components/schemas/AccessEvent"
        - $ref: "#/components/schemas/GrantSyncEvent"
        - $ref: "#/components/schemas/BatteryEvent"
        - $ref: "#/components/schemas/DoorStateEvent"
        - $ref: "#/components/schemas/ResetEvent"
        - $ref: "#/components/schemas/LocalPinSyncEvent"
        - $ref: "#/components/schemas/RebootEvent"
      discriminator:
        propertyName: type
        mapping:
          access: "#/components/schemas/AccessEvent"
          credential_holder_grant_sync: "#/components/schemas/GrantSyncEvent"
          battery: "#/components/schemas/BatteryEvent"
          door_state: "#/components/schemas/DoorStateEvent"
          reset: "#/components/schemas/ResetEvent"
          local_pin_sync: "#/components/schemas/LocalPinSyncEvent"
          reboot: "#/components/schemas/RebootEvent"

    AccessEvent:
      title: access
      description: Someone locked, unlocked or was denied at the lock.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          properties:
            type:
              type: string
              const: access
            action:
              type: string
              enum: [access_denied, locked, unlocked]
              example: unlocked
            method:
              type: [string, "null"]
              enum: [keypad, local_pin, smart_pin, auto_lock, nfc, wifi, button_relay, ble, unikey, schedule, manual_knob, null]
              description: How the lock was operated.
              example: keypad
            source_type:
              type: [string, "null"]
              enum: [user, credential_holder, null]
              description: Who caused the event, when known. `null` for SmartPIN, local PIN, schedule, auto-lock and manual knob events.
              example: credential_holder
            source_name:
              type: [string, "null"]
              description: The name of the user or credential holder, when known.
              example: Cleaning crew
            credential_holder_id:
              type: [string, "null"]
              format: uuid
              description: Set when `source_type` is `credential_holder`.
              example: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
            credential_holder_external_uuid:
              type: [string, "null"]
              format: uuid
              description: Set when `source_type` is `credential_holder`.
              example: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
            credential_holder_grant_id:
              type: [string, "null"]
              format: uuid
              description: The grant used. Set when `source_type` is `credential_holder`.
              example: e0d0db29-45a0-4a80-a438-9bf44e356f6a
            credential_holder_grant_external_uuid:
              type: [string, "null"]
              format: uuid
              description: Set when `source_type` is `credential_holder`.
              example: 15c438f9-7554-4bd1-99b3-762234a9bf27
            user_id:
              type: [string, "null"]
              format: uuid
              description: The KoreLock user. Set when `source_type` is `user`.
              example: null
            pin:
              type: [string, "null"]
              description: The PIN used, for the `keypad`, `local_pin` and `auto_lock` methods.
              example: "49820"
            card_number:
              type: [string, "null"]
              description: The card used, for the `nfc` method.
              example: null
            smart_pin:
              type: [string, "null"]
              description: The SmartPIN used, for the `smart_pin` method.
              example: null
            source_external_uuid:
              type: [string, "null"]
              deprecated: true
              description: Deprecated. Same as `credential_holder_external_uuid`.
            source_entity_type:
              type: [string, "null"]
              deprecated: true
              description: Deprecated. Same as `source_type`.
            source_entity_name:
              type: [string, "null"]
              deprecated: true
              description: Deprecated. Same as `source_name`.

    GrantSyncEvent:
      title: credential_holder_grant_sync
      description: A grant was added to or removed from the lock.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - type: object
          properties:
            type:
              type: string
              const: credential_holder_grant_sync
            action:
              type: string
              enum: [upsert_success, delete_success]
              example: upsert_success
            credential_holder_id:
              type: string
              format: uuid
              example: c3f1a9d2-5b7e-4c08-9a61-e2d4b8f07c35
            credential_holder_external_uuid:
              type: [string, "null"]
              format: uuid
              example: b695376d-5fe4-4e6a-b4f3-e96fd39fd6a2
            credential_holder_grant_id:
              type: string
              format: uuid
              description: The grant that synced.
              example: e0d0db29-45a0-4a80-a438-9bf44e356f6a
            credential_holder_grant_external_uuid:
              type: [string, "null"]
              format: uuid
              example: 15c438f9-7554-4bd1-99b3-762234a9bf27

    BatteryEvent:
      title: battery
      description: The battery is low or was replaced.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - $ref: "#/components/schemas/LegacyEventFields"
        - type: object
          properties:
            type:
              type: string
              const: battery
            action:
              type: string
              enum: [battery_low, battery_replaced]
              example: battery_low

    DoorStateEvent:
      title: door_state
      description: The door sensor reported the door open, closed or unknown.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - $ref: "#/components/schemas/LegacyEventFields"
        - type: object
          properties:
            type:
              type: string
              const: door_state
            action:
              type: string
              enum: [door_opened, door_closed, door_state_unknown]
              example: door_opened

    ResetEvent:
      title: reset
      description: The lock was reset or had codes erased.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - $ref: "#/components/schemas/LegacyEventFields"
        - type: object
          properties:
            type:
              type: string
              const: reset
            action:
              type: string
              enum: [factory_reset, erased_local_pins, erased_access_codes]
              example: factory_reset

    LocalPinSyncEvent:
      title: local_pin_sync
      description: A local PIN was added to or removed from the lock.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - $ref: "#/components/schemas/LegacyEventFields"
        - type: object
          properties:
            type:
              type: string
              const: local_pin_sync
            action:
              type: string
              enum: [added_local_pin, removed_local_pin]
              example: added_local_pin

    RebootEvent:
      title: reboot
      description: The lock powered on.
      allOf:
        - $ref: "#/components/schemas/EventBase"
        - $ref: "#/components/schemas/LegacyEventFields"
        - type: object
          properties:
            type:
              type: string
              const: reboot
            action:
              type: string
              enum: [power_on]
              example: power_on
    EventPageMetadata:
      type: object
      description: Event lists don't include `pages` or `total_count`.
      properties:
        page: { type: integer, example: 1 }
        per_page: { type: integer, example: 20 }
        has_more_items: { type: boolean, example: true }
    EventList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Event" }
        metadata: { $ref: "#/components/schemas/EventPageMetadata" }
    WebhookEventType:
      type: string
      enum: [access, credential_holder_grant_sync, "*"]
      description: An event type to send. `*` means every type and must be used on its own.
    Webhook:
      type: object
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          example: 2bd463b9-ef85-4349-bb51-371b32069d78
        url:
          type: string
          format: uri
          pattern: "^https://"
          description: Where KoreLock sends events. Must use HTTPS.
          example: https://example.com/korelock/webhooks
        enabled:
          type: boolean
          description: Whether deliveries are on.
          example: true
        disabled_by:
          type: [string, "null"]
          enum: [user, circuit_breaker, null]
          readOnly: true
          description: Why the webhook is off. `user` if `enabled` was set to `false`, `circuit_breaker` if KoreLock turned it off. `null` while it's on.
          # TODO(confirm): Nothing in current code sets circuit_breaker. Keep, or drop it from the docs?
          example: null
        secret:
          type: [string, "null"]
          description: |
            Signs each delivery. When set, deliveries include an `X-Hub-Signature` header: `sha256=` plus the HMAC-SHA256 hex digest of the body. Returned as set, so keep responses private.
          example: null
        subscribed_events:
          type: array
          minItems: 1
          description: The event types to send. Use `["*"]` for all of them.
          uniqueItems: true
          items: { $ref: "#/components/schemas/WebhookEventType" }
          example: [access]
        created_at: { type: string, format: date-time, readOnly: true, example: "2026-10-12T17:40:03Z" }
        updated_at: { type: string, format: date-time, readOnly: true, example: "2026-10-12T17:40:03Z" }
    WebhookInput:
      type: object
      required: [url, subscribed_events]
      properties:
        url: { type: string, format: uri, pattern: "^https://", description: Must use HTTPS and be unique in the account. }
        enabled: { type: boolean, default: true }
        secret: { type: string, description: Signs each delivery with `X-Hub-Signature`. }
        subscribed_events:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/WebhookEventType" }
    WebhookUpdate:
      type: object
      properties:
        url: { type: string, format: uri, pattern: "^https://", description: Must use HTTPS. }
        enabled: { type: boolean }
        secret: { type: string }
        subscribed_events:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/WebhookEventType" }
    WebhookList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/Webhook" }
        metadata: { $ref: "#/components/schemas/PageMetadata" }
    Error:
      type: object
      properties:
        error_type:
          type: string
          description: A stable code for the kind of error.
          example: user_access_expired
        error_message:
          type: string
          description: A readable description of the error.
          example: User access expired
    ValidationError:
      type: object
      description: The body of a `400`. Only `validation_failed` errors include `validation_errors`.
      properties:
        error_type:
          type: string
          enum: [validation_failed, unpermitted_params, invalid_parameter]
          example: validation_failed
        error_message:
          type: string
          example: Validation failed
        validation_errors:
          type: array
          description: One entry per problem.
          items:
            type: object
            properties:
              attribute:
                type: string
                description: The field with the problem, or `self` for the record as a whole.
                example: name
              type:
                type: string
                description: A stable code for the problem, such as `blank`, `taken` or `in_use`.
                example: blank
              message:
                type: string
                example: Name can't be blank
        parameter:
          type: string
          description: For `invalid_parameter`, the query parameter that was out of range.
          example: per_page
        errors:
          type: array
          deprecated: true
          description: Deprecated. The same problems as plain messages; use `validation_errors`.
          items: { type: string }
    RateLimitError:
      type: object
      properties:
        error_message: { type: string, example: "Rate limit exceeded, please slow down and retry later" }
        error_type: { type: string, example: rate_limit_exceeded }
