openapi: 3.1.0
info:
  title: Sparro API
  version: "2"
  description: >
    Read access to a connected Sparro company's invoices, customers, shifts
    and mileage, and writes for shifts, hours and mileage. Authenticate
    with a bearer token from the OAuth2 flow below, or (for an org's own
    integrations on the launchpad/scaleup plan) a first-party API key
    generated from Sparro's own /settings page — both use the same bearer
    header and the same read endpoints. Writes need a token from the OAuth2
    flow and an Idempotency-Key header.
servers:
  - url: https://app.sparro.dk/api/v2
paths:
  /oauth/authorize:
    get:
      summary: Start the authorization-code flow
      description: >
        Redirect the org owner's browser here. PKCE (code_challenge,
        code_challenge_method=S256) is required for public clients. Only an
        owner of the company can approve.
      parameters:
        - name: client_id
          in: query
          required: true
          schema: { type: string }
        - name: redirect_uri
          in: query
          required: true
          schema: { type: string }
        - name: scope
          in: query
          required: true
          description: Space-separated list of requested scopes.
          schema: { type: string }
        - name: state
          in: query
          schema: { type: string }
        - name: code_challenge
          in: query
          schema: { type: string }
        - name: code_challenge_method
          in: query
          schema: { type: string, enum: [S256] }
      responses:
        "307":
          description: Redirects to the Sparro-hosted consent screen.
  /oauth/token:
    post:
      summary: Exchange a code or refresh token for an access token
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                grant_type: { type: string, enum: [authorization_code, refresh_token] }
                client_id: { type: string }
                client_secret: { type: string, description: Required for confidential clients. }
                code: { type: string }
                redirect_uri: { type: string }
                code_verifier: { type: string, description: Required if the authorize request set a code_challenge. }
                refresh_token: { type: string }
      responses:
        "200":
          description: Token pair issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  refresh_token: { type: string }
                  token_type: { type: string, enum: [Bearer] }
                  expires_in: { type: integer, description: Seconds, always 3600. }
                  scope: { type: string }
        "400":
          description: invalid_request, invalid_grant, invalid_scope, or unsupported_grant_type.
        "401":
          description: invalid_client.
  /org:
    get:
      summary: Get the connected company
      security: [{ bearerAuth: [] }]
      description: Requires the `org:read` scope.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  org:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      cvr: { type: string, nullable: true }
                      created_at: { type: string, format: date-time }
        "401": { description: Missing, invalid, expired, or revoked token. }
        "404": { description: Token doesn't carry the org:read scope. }
        "429": { description: Rate limit exceeded. }
  /invoices:
    get:
      summary: List invoices
      security: [{ bearerAuth: [] }]
      description: Requires the `invoices:read` scope.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoices:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        invoice_number: { type: integer }
                        customer_id: { type: string }
                        customer_name: { type: string }
                        status: { type: string }
                        issue_date: { type: string, format: date }
                        due_date: { type: string, format: date }
                        currency: { type: string }
                        total_ore: { type: integer, description: Minor units — DKK = total_ore / 100. }
                        created_at: { type: string, format: date-time }
        "401": { description: Missing, invalid, expired, or revoked token. }
        "404": { description: Token doesn't carry the invoices:read scope. }
        "429": { description: Rate limit exceeded. }
  /customers:
    get:
      summary: List customers
      security: [{ bearerAuth: [] }]
      description: Requires the `customers:read` scope.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  customers:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        email: { type: string, nullable: true }
                        address: { type: string, nullable: true }
                        cvr: { type: string, nullable: true }
                        ean: { type: string, nullable: true }
                        created_at: { type: string, format: date-time }
        "401": { description: Missing, invalid, expired, or revoked token. }
        "404": { description: Token doesn't carry the customers:read scope. }
        "429": { description: Rate limit exceeded. }
  /shifts:
    get:
      summary: List shifts
      security: [{ bearerAuth: [] }]
      description: Requires the `shifts:read` scope. Paginated by cursor.
      parameters:
        - name: cursor
          in: query
          schema: { type: string, description: The next_cursor of the previous page. }
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  shifts:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        employee_ref: { type: string, description: Opaque; different for every app. }
                        start_time: { type: string, format: date-time }
                        end_time: { type: string, format: date-time }
                        hours: { type: number, description: End minus start minus the unpaid break. }
                  next_cursor: { type: string, nullable: true }
        "400": { description: Invalid cursor. }
        "401": { description: Missing, invalid, expired, or revoked token. }
        "404": { description: Token doesn't carry the shifts:read scope. }
        "429": { description: Rate limit exceeded. }
    post:
      summary: Create a shift
      security: [{ bearerAuth: [] }]
      description: Requires the `shifts:write` scope and an `Idempotency-Key` header.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShiftWindow" }
      responses:
        "201":
          description: Created, or the first response again when the key was used before (header `Idempotent-Replayed`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShiftResource" }
        "400": { $ref: "#/components/responses/WriteError" }
        "403": { $ref: "#/components/responses/WriteError" }
        "404": { $ref: "#/components/responses/WriteError" }
        "409": { $ref: "#/components/responses/WriteError" }
        "413": { $ref: "#/components/responses/WriteError" }
        "422": { $ref: "#/components/responses/WriteError" }
        "429": { $ref: "#/components/responses/WriteError" }
  /shifts/hours:
    post:
      summary: Register hours
      security: [{ bearerAuth: [] }]
      description: Requires the `shifts:write` scope and an `Idempotency-Key` header. The entry is submitted until the owner approves it.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ShiftWindow" }
      responses:
        "201":
          description: Created, or the first response again when the key was used before (header `Idempotent-Replayed`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ShiftResource" }
        "400": { $ref: "#/components/responses/WriteError" }
        "403": { $ref: "#/components/responses/WriteError" }
        "404": { $ref: "#/components/responses/WriteError" }
        "409": { $ref: "#/components/responses/WriteError" }
        "413": { $ref: "#/components/responses/WriteError" }
        "422": { $ref: "#/components/responses/WriteError" }
        "429": { $ref: "#/components/responses/WriteError" }
  /mileage:
    get:
      summary: List mileage
      security: [{ bearerAuth: [] }]
      description: Requires the `mileage:read` scope. Paginated by cursor.
      parameters:
        - name: cursor
          in: query
          schema: { type: string, description: The next_cursor of the previous page. }
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 500 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  mileage:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        employee_ref: { type: string, description: Opaque; different for every app. }
                        date: { type: string, format: date }
                        distance_km: { type: number }
                  next_cursor: { type: string, nullable: true }
        "400": { description: Invalid cursor. }
        "401": { description: Missing, invalid, expired, or revoked token. }
        "404": { description: Token doesn't carry the mileage:read scope. }
        "429": { description: Rate limit exceeded. }
    post:
      summary: Create a mileage entry
      security: [{ bearerAuth: [] }]
      description: Requires the `mileage:write` scope and an `Idempotency-Key` header. The trip waits for the owner's approval.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [employee_ref, date, distance_km, purpose]
              additionalProperties: false
              properties:
                employee_ref: { type: string }
                date: { type: string, format: date, description: Not later than tomorrow and not older than 13 months. }
                distance_km: { type: number, exclusiveMinimum: 0, maximum: 1000 }
                purpose: { type: string, minLength: 1, maxLength: 120 }
      responses:
        "201":
          description: Created, or the first response again when the key was used before (header `Idempotent-Replayed`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  employee_ref: { type: string }
                  date: { type: string, format: date }
                  distance_km: { type: number }
                  status: { type: string, enum: [awaiting_approval] }
        "400": { $ref: "#/components/responses/WriteError" }
        "403": { $ref: "#/components/responses/WriteError" }
        "404": { $ref: "#/components/responses/WriteError" }
        "409": { $ref: "#/components/responses/WriteError" }
        "413": { $ref: "#/components/responses/WriteError" }
        "422": { $ref: "#/components/responses/WriteError" }
        "429": { $ref: "#/components/responses/WriteError" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: 8 to 128 printable ASCII characters, new for each logical operation. Kept for 24 hours.
      schema: { type: string, minLength: 8, maxLength: 128 }
  responses:
    WriteError:
      description: Error with a machine-readable code (see the Errors page).
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              error_description: { type: string }
              fields: { type: array, items: { type: string }, description: "Only on validation_failed: the names of the offending fields." }
  schemas:
    ShiftWindow:
      type: object
      required: [employee_ref, start_time, end_time]
      additionalProperties: false
      properties:
        employee_ref: { type: string }
        start_time: { type: string, format: date-time, description: With a UTC offset, to the minute. }
        end_time: { type: string, format: date-time, description: With a UTC offset, to the minute. After start_time, at most 24 hours later. }
        break_minutes: { type: integer, minimum: 0, maximum: 720, default: 0 }
    ShiftResource:
      type: object
      properties:
        id: { type: string }
        employee_ref: { type: string }
        start_time: { type: string, format: date-time }
        end_time: { type: string, format: date-time }
        hours: { type: number }
        status: { type: string, enum: [planned, submitted] }
