openapi: 3.0.3

info:
  title: Florence Rota API
  version: "1.0.0"
  description: |
    Partner access to a Florence customer's own data — read the client's people,
    sites and roles, and write records back against them.

    Florence is the source of truth for who works where. Your system is the
    source of truth for the records it sends.

    ## Base URL

    ```
    https://api.florence.co.uk/api/rota/v1
    ```

    Every path below is shown in full, so `GET /api/rota/v1/workers` is
    `https://api.florence.co.uk/api/rota/v1/workers`.

    Paths are scoped `/api/{family}/{version}`. This document covers `rota` —
    people, sites, roles and the training recorded against them. Other families
    version independently, so a future `/api/rota/v2` leaves them untouched.

    ## Authentication

    Every request carries your API key as a bearer token:

    ```
    Authorization: Bearer flo_kZ8vQ2xL9pR4mN7tW1yB6cD3fH5jS0aE
    ```

    Keys are issued per customer. A key is scoped to exactly one company, and
    every endpoint returns only that company's data — there is no parameter that
    widens it. A customer who uses two Florence companies gives you two keys.

    The key is shown once, at creation. Florence stores only a hash of it and
    cannot tell you the value later, so record it when you receive it. Ask your
    Florence contact to issue a replacement if it is lost; two keys can be live
    at once, so rotation needs no downtime — start using the new one, then have
    the old one revoked.

    Treat the key as a password. Send it only over HTTPS and only in the
    `Authorization` header — never in a query string, where it would be captured
    by access logs.

    ## Scopes

    | Scope | Grants |
    | --- | --- |
    | `workers:read` | `/api/rota/v1/workers`, `/api/rota/v1/locations`, `/api/rota/v1/worker_types` |
    | `training:write` | `/api/rota/v1/courses`, `/api/rota/v1/training_records` |

    ## Identifiers

    Every id in this API is a Florence id, stable for the life of the record and
    never reused. Store them as mapping keys against your own.

    ## Keeping in step

    Poll with `updated_since` and you get only what changed. Florence has no
    overnight batch, so a change is visible within a second of a manager making
    it — how quickly a new starter reaches you is your polling interval, nothing
    else. Hourly is plenty for most customers.

    `last_modified` on a worker accounts for their assignments as well as their
    own record, so a role or location change moves it even when nothing on the
    person changed.

    ## Errors

    Failures return a JSON body of the shape:

    ```json
    { "error": { "code": "invalid_records", "message": "…", "details": [ … ] } }
    ```

    Treat `code` as the stable value and `message` as human-readable prose that
    may be reworded. `details` appears only on the batch errors, and lists each
    problem by its index in the array you sent.

    Every code the API can return:

    | Status | `code` | Meaning | What to do |
    | --- | --- | --- | --- |
    | 401 | `missing_credentials` | No `Authorization: Bearer` header. | Send the key. |
    | 401 | `invalid_credentials` | Key unknown, revoked, expired, or its integration is no longer installed. | Stop and ask for a new key — retrying will not help. |
    | 403 | `insufficient_scope` | Valid key, but it lacks the scope this endpoint needs. | Ask for a key with the missing scope. |
    | 404 | `not_found` | No such record within this customer's data. | Treat as deleted or out of scope; do not retry. |
    | 422 | `invalid_parameter` | A query parameter could not be read, e.g. a malformed `updated_since`. | Fix the request. |
    | 422 | `missing_parameter` | A required body field is absent, e.g. no `records` array. | Fix the request. |
    | 422 | `empty_batch` | The batch array — `records` or `courses` — was present but empty. | Skip the call when you have nothing to send. |
    | 422 | `batch_too_large` | More items than the endpoint accepts. | Split into smaller batches. |
    | 422 | `invalid_courses` | One or more courses were rejected. **Nothing was saved.** | Read `details`, fix those entries, resend the batch. |
    | 422 | `invalid_records` | One or more training records were rejected. **Nothing was applied.** | Read `details`, fix those entries, resend the batch. |
    | 429 | `rate_limited` | Too many requests for this key. | Back off and retry after `Retry-After` seconds. |
    | 5xx | — | Our fault. Response body may not be JSON. | Retry with backoff; the write endpoints are idempotent, so a retry is safe. |

    A `422` on a batch is all-or-nothing. Nothing in that request was written, so
    the fix is always to correct the flagged entries and send the whole batch
    again — never to assume a partial write landed.

servers:
  - url: https://api.florence.co.uk
    description: United Kingdom

security:
  - bearerAuth: []

tags:
  - name: Roster
    description: Read the customer's people, sites and roles.
  - name: Training
    description: Register your courses, then report progress against them.

paths:
  /api/rota/v1/workers:
    get:
      tags: [Roster]
      summary: List the customer's workers
      description: |
        Returns the company's own employed workers — not Florence marketplace or
        agency staff. Leavers stay in the response with
        `account_status: inactive` rather than disappearing.

        Paginate by passing the last id you saw as `after`. Combine with
        `updated_since` for incremental syncs.
      parameters:
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
          description: ISO-8601. Only workers changed at or after this moment.
        - name: location_id
          in: query
          schema: { type: integer }
          description: Restrict to workers assigned to one location.
        - name: after
          in: query
          schema: { type: integer }
          description: Return workers with an id greater than this. Use `meta.next_cursor`.
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 200 }
      responses:
        "200":
          description: A page of workers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Worker" }
                  meta: { $ref: "#/components/schemas/PageMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/InvalidParameter" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/rota/v1/workers/{id}:
    get:
      tags: [Roster]
      summary: Fetch one worker
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: The worker.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Worker" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/rota/v1/locations:
    get:
      tags: [Roster]
      summary: List the customer's locations
      description: |
        A location is a physical site — one care home, one address. Florence
        also models units (wards, floors) below this and regions above it;
        neither is returned here, and a Boost-style flat location concept maps
        to this level.
      parameters:
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: The company's locations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Location" }
                  meta: { $ref: "#/components/schemas/CountMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/rota/v1/worker_types:
    get:
      tags: [Roster]
      summary: List the roles in use at this customer
      description: |
        Florence calls roles "worker types". Only those actually assigned to one
        of this company's workers are returned, so the list is the vocabulary
        for `worker_type_ids` on a worker and nothing wider.

        Names are unique within a company. Some worker types are platform-wide
        and shared across customers — those carry a null `company_id`.

        No permission, access-level or seniority information is exposed here,
        and none is planned. What a role may do in your system is yours to
        decide.
      parameters:
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: The roles in use.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WorkerType" }
                  meta: { $ref: "#/components/schemas/CountMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/rota/v1/courses:
    get:
      tags: [Training]
      summary: List the courses you have registered
      responses:
        "200":
          description: Your registered courses.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Course" }
                  meta: { $ref: "#/components/schemas/CountMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Training]
      summary: Register or rename courses
      description: |
        Register a course before reporting training against it. Matching is on
        `external_id` alone, so renaming a course updates the existing one
        rather than creating a second.

        This is a precondition rather than a convenience: because Florence knows
        your catalogue up front, `/api/rota/v1/training_records` can reject an unknown
        course id while you are still on the call, instead of failing silently
        in a background job you cannot see.

        Idempotent — sending your whole catalogue nightly is fine.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [courses]
              properties:
                courses:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: object
                    required: [external_id, title]
                    properties:
                      external_id:
                        type: string
                        description: Your identifier for the course. Stable, never reused.
                      title:
                        type: string
                        description: Shown to the customer on the worker's training tab.
            example:
              courses:
                - external_id: "fa-l3"
                  title: "First Aid at Work (Level 3)"
                - external_id: "moving-handling"
                  title: "Moving & Handling"
      responses:
        "200":
          description: Courses registered.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Course" }
                  meta: { $ref: "#/components/schemas/CountMeta" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/InvalidCourseBatch" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/rota/v1/training_records:
    post:
      tags: [Training]
      summary: Report training progress
      description: |
        Always a batch: send an array even for a single record. There is no
        single-record endpoint to graduate from.

        Every reference is checked before the request returns. If any record
        names a worker or course Florence does not recognise, **the whole batch
        is rejected and nothing is applied** — the response lists each problem
        by array index. A `202` therefore means every record will be applied,
        not merely that the payload arrived.

        Records are applied in the background and are idempotent on
        (worker, course): re-sending the same batch overwrites rather than
        duplicating, so a retry after a timeout is always safe.

        ### On `due_on`

        `due_on` is **the date the training next needs doing** — for a completed
        course, its expiry. It is not a "finish by" deadline for work in
        progress. A first aid certificate completed in March 2026 and valid
        three years is `completed_at: 2026-03-14`, `due_on: 2029-03-14`.

        Florence treats a record whose `due_on` has passed as expired and shows
        it as overdue to the customer regardless of the `status` you sent, so a
        certificate cannot quietly display as current after it lapses. Send
        `due_on` wherever the training expires.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [records]
              properties:
                records:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items: { $ref: "#/components/schemas/TrainingRecordInput" }
            example:
              records:
                - worker_id: 84213
                  course_external_id: "fa-l3"
                  assigned_at: "2026-02-01T09:00:00Z"
                  completed_at: "2026-03-14T15:22:00Z"
                  due_on: "2029-03-14T00:00:00Z"
                  status: completed
                - worker_id: 84214
                  course_external_id: "moving-handling"
                  assigned_at: "2026-08-01T09:00:00Z"
                  due_on: "2026-09-01T00:00:00Z"
      responses:
        "202":
          description: |
            Every record validated and will be applied. `batch_id` identifies
            this submission in Florence's logs — quote it when asking about a
            batch.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch_id: { type: string, format: uuid }
                      accepted: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/InvalidTrainingBatch" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "`Authorization: Bearer <api_key>`"

  schemas:
    Worker:
      type: object
      properties:
        id:
          type: integer
          description: Florence's worker id. Stable, never reused.
        first_name: { type: string }
        last_name: { type: string }
        email:
          type: string
          format: email
          description: |
            **Frequently absent.** Florence does not require an email address
            for a client worker — they sign in with a link rather than a
            password — and slightly over half of workers have none. The field is
            omitted entirely when there is no address; it is never an empty
            string. If your system logs users in by email, plan for this rather
            than treating it as a data-quality problem to be fixed later.

            Addresses are not guaranteed unique. A small number of workers share
            one with a family member.
        account_status:
          type: string
          enum: [active, inactive]
          description: |
            `active` covers anyone currently employed, including a worker with
            no current unit assignment — they have not left. `inactive` means
            every assignment they hold is archived, which is the leaver signal.

            A worker is never deleted from this endpoint on leaving. They are
            only ever absent if the customer has exercised a GDPR erasure
            request, which Florence must honour.
        employment_start_date:
          type: string
          format: date
          description: |
            The date they started with the organisation, which may be backdated.

            **Omitted when not held**, which is most workers — the field lives
            on an employment contract record that many customers do not use.
            Where it is present it is real; there is no inferred value mixed in.
            Do not substitute `record_created_at` for it.
        employment_end_date:
          type: string
          format: date
          description: |
            Last day worked, not the first day without access. Present only once
            every contract the worker holds has ended, and only for customers
            using contracts — for most leavers the `account_status` change is
            the operative signal.
        record_created_at:
          type: string
          format: date-time
          description: |
            When this worker's record was created in Florence. Always present.

            **This is not a start date.** Customers join Florence by importing
            their existing staff in one go, so a whole workforce commonly shares
            one value — for such a cohort it is out by a median of well over a
            year. Where a worker joined after their employer was already using
            Florence it is a reasonable approximation, out by a median of about
            three weeks. You can tell the two apart yourself: when a large share
            of one customer's workers share a single value, that is the import
            date and should be discounted.
        location_ids:
          type: array
          items: { type: integer }
          description: Every location they are assigned to. Multi-site workers are common.
        worker_type_ids:
          type: array
          items: { type: integer }
          description: |
            Distinct roles held, from `/worker_types`. Roles are assigned per
            unit, so a worker at two sites may hold different roles at each;
            this is the flattened set.
        company_id:
          type: integer
          description: The Florence company this worker belongs to.
        last_modified:
          type: string
          format: date-time
          description: |
            The latest change to the worker **or** to any of their assignments.
            Use this, not your own received-at time, when advancing a cursor.
      required: [id, first_name, last_name, account_status, record_created_at, last_modified]

    Location:
      type: object
      properties:
        id: { type: integer }
        name:
          type: string
          description: |
            Not guaranteed unique within a company — Florence does not enforce
            it and a handful of duplicates exist. If your system requires
            uniqueness, raise the specific customer with us and we will clean
            the data rather than have you disambiguate blindly.
        company_id: { type: integer }
        company_region_id:
          type: integer
          description: |
            An optional grouping above location. Lightly used and frequently
            null, so do not rely on it to partition a customer without agreeing
            it for that customer first.
        service_type:
          type: string
          description: |
            Florence's home type, e.g. "Nursing". A rota classification rather
            than a regulatory one, and free text per platform — map it to your
            own taxonomy rather than consuming it directly.
        last_modified: { type: string, format: date-time }
      required: [id, name, company_id, last_modified]

    WorkerType:
      type: object
      properties:
        id: { type: integer }
        name:
          type: string
          description: Unique within a company.
        company_id:
          type: integer
          nullable: true
          description: Null for platform-wide roles shared across customers.
        parent_worker_type_id:
          type: integer
          nullable: true
          description: |
            An optional parent role, the nearest thing Florence has to a job
            family or department. Inconsistently populated — treat it as a hint
            that is often absent rather than a grouping you can depend on.
        last_modified: { type: string, format: date-time }
      required: [id, name, last_modified]

    Course:
      type: object
      properties:
        external_id: { type: string }
        title: { type: string }
        last_modified: { type: string, format: date-time }
      required: [external_id, title, last_modified]

    TrainingRecordInput:
      type: object
      required: [worker_id, course_external_id]
      properties:
        worker_id:
          type: integer
          description: A Florence worker id from `/workers`.
        course_external_id:
          type: string
          description: Your course id, already registered via `/api/rota/v1/courses`.
        assigned_at:
          type: string
          format: date-time
          description: When the course was assigned. Defaults to now on first sight of a record.
        due_on:
          type: string
          format: date-time
          description: |
            When the training next needs doing — the expiry date for completed
            training. Not a "finish by" deadline.
        completed_at:
          type: string
          format: date-time
          description: When the worker last completed it.
        status:
          type: string
          enum: [not_completed, completed, overdue]
          description: |
            Optional. Defaults to `completed` when `completed_at` is given and
            `not_completed` otherwise. Florence overrides a stale `completed`
            for display once `due_on` has passed.

    PageMeta:
      type: object
      properties:
        count: { type: integer }
        limit: { type: integer }
        next_cursor:
          type: integer
          nullable: true
          description: Pass as `after` for the next page. Null when the page was empty.

    CountMeta:
      type: object
      properties:
        count: { type: integer }

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
            details:
              type: array
              items:
                type: object
                properties:
                  index:
                    type: integer
                    description: Position in the array you submitted.
                  message: { type: string }

  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            missing:
              summary: No Authorization header
              value:
                error:
                  code: missing_credentials
                  message: "Provide an Authorization: Bearer <api_key> header."
            invalid:
              summary: Key unknown, revoked or expired
              value:
                error:
                  code: invalid_credentials
                  message: The credentials provided are not valid.
    Forbidden:
      description: The key lacks the scope this endpoint needs.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: insufficient_scope
              message: This credential does not hold the training:write scope.
    NotFound:
      description: No such record within this customer's data.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: not_found
              message: Not found
    InvalidParameter:
      description: A query parameter could not be understood.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: invalid_parameter
              message: updated_since must be an ISO-8601 timestamp
    InvalidCourseBatch:
      description: One or more courses were rejected. Nothing was saved.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            rejected_courses:
              summary: A course missing its title
              value:
                error:
                  code: invalid_courses
                  message: One or more courses were rejected. Nothing was saved.
                  details:
                    - index: 1
                      message: title is required
            empty:
              summary: Nothing to send
              value:
                error:
                  code: empty_batch
                  message: courses must contain at least one entry.
            too_large:
              summary: Over the per-request limit
              value:
                error:
                  code: batch_too_large
                  message: A maximum of 500 courses may be sent in one request.
            missing:
              summary: The array itself was absent
              value:
                error:
                  code: missing_parameter
                  message: "Missing required parameter: courses"
    InvalidTrainingBatch:
      description: One or more records were rejected. Nothing was applied.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            rejected_records:
              summary: Unknown course and unknown worker
              value:
                error:
                  code: invalid_records
                  message: One or more records were rejected. Nothing was applied.
                  details:
                    - index: 3
                      message: course_external_id fa-l4 has not been registered — POST it to /api/rota/v1/courses first
                    - index: 7
                      message: worker_id 99999 is not a worker at this company
            empty:
              summary: Nothing to send
              value:
                error:
                  code: empty_batch
                  message: records must contain at least one entry.
            too_large:
              summary: Over the per-request limit
              value:
                error:
                  code: batch_too_large
                  message: A maximum of 1000 records may be sent in one request.
            missing:
              summary: The array itself was absent
              value:
                error:
                  code: missing_parameter
                  message: "Missing required parameter: records"
    RateLimited:
      description: |
        Too many requests for this key. Retry after the number of seconds given
        in the `Retry-After` header.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retrying.
        RateLimit-Limit:
          schema: { type: integer }
          description: Requests permitted per minute.
        RateLimit-Remaining:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: rate_limited
              message: Rate limit of 300 requests per minute exceeded.
