openapi: 3.1.0
info:
  title: RVVontheffing.nl Partner API
  version: "1.0"
  description: |
    Server-to-server API for integration partners of RVVontheffing.nl.

    Two integration profiles are supported:

    * **Mutations** (scopes `mutaties:bestuurders` and `mutaties:voertuigen`):
      create, update and cancel drivers and vehicles for one organisation.
      Every mutation key is bound to a single organisation ("houder"); it can
      never read or write data of any other organisation.
    * **License plate feed** (scope `kentekens:lezen`): a read-only feed of all
      license plates that currently hold a valid RVV exemption, intended for
      ANPR / enforcement systems.

    All requests require a bearer API key issued by RVVontheffing.nl. Keys are
    shown once at creation and can be revoked at any time.
  contact:
    name: RVVontheffing.nl
    url: https://rvvontheffing.nl/contact
servers:
  - url: https://rvvontheffing.nl/api/v1
security:
  - bearerAuth: []
tags:
  - name: License plate feed
  - name: Drivers
  - name: Vehicles
  - name: Entities

paths:
  /license-plates:
    get:
      tags: [License plate feed]
      operationId: listLicensePlates
      summary: All license plates with a currently valid exemption
      description: |
        Returns exactly one row per license plate. The list includes cards that
        are in circulation today, including the edge case where a previous card
        remains the only valid one until a renewal takes effect.

        Supports conditional requests: send the `ETag` value back via
        `If-None-Match` to receive `304 Not Modified` when nothing changed.
        Poll at most once every 5 minutes; responses are cacheable for 300
        seconds. Rate limit: 30 requests per minute.

        Requires scope `kentekens:lezen`.
      parameters:
        - name: format
          in: query
          description: "`csv` returns a bare list, one plate per line, no header."
          schema:
            type: string
            enum: [csv]
        - name: If-None-Match
          in: header
          schema:
            type: string
      responses:
        "200":
          description: The current feed.
          headers:
            ETag:
              schema: { type: string }
            Cache-Control:
              schema: { type: string, example: "private, max-age=300" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  generated_at: { type: string, format: date-time }
                  count: { type: integer }
                  plates:
                    type: array
                    items:
                      type: object
                      properties:
                        license_plate: { type: string, example: "VDN27H" }
                        valid_from: { type: [string, "null"], format: date }
                        valid_until: { type: [string, "null"], format: date }
            text/csv:
              schema: { type: string, example: "VDN27H\nV952PL\n" }
        "304":
          description: Feed unchanged since the given ETag.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /entities:
    get:
      tags: [Entities]
      operationId: listEntities
      summary: Entities of the organisation
      description: |
        Lists the entities (branches, departments, subcontractors) of the
        organisation the API key is bound to. Organisations that are configured
        as entity-mandatory require an `entity_id` when creating drivers and
        vehicles; use this endpoint to obtain the ids.

        Requires scope `mutaties:bestuurders` or `mutaties:voertuigen`.
      responses:
        "200":
          description: All entities.
          content:
            application/json:
              schema:
                type: object
                properties:
                  entities:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        type:
                          type: string
                          enum: [branch, department, subcontractor]
                        parent_id: { type: [string, "null"], format: uuid }
                        active: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /drivers:
    get:
      tags: [Drivers]
      operationId: listDrivers
      summary: List or look up drivers
      description: Requires scope `mutaties:bestuurders`.
      parameters:
        - name: email
          in: query
          description: Exact match, case-insensitive.
          schema: { type: string, format: email }
        - name: active
          in: query
          schema: { type: string, enum: ["true", "false"] }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Matching drivers, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer, description: Total matches ignoring paging. }
                  drivers:
                    type: array
                    items: { $ref: "#/components/schemas/Driver" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Drivers]
      operationId: createDriver
      summary: Create a driver
      description: |
        Creates the driver account (no password; the driver receives an
        activation email to set one), optionally registers a vehicle by license
        plate, and requests the window card where possible.

        When `license_plate` is provided it is validated against the Dutch RDW
        vehicle register. If the plate is new to the platform a vehicle plus
        card request is created (`license_plate_result: vehicle_created`); if
        the plate is already registered a link request is created for approval
        (`license_plate_result: link_request_created`).

        Requires scope `mutaties:bestuurders`. Supports `Idempotency-Key`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateDriverRequest" }
      responses:
        "201":
          description: Driver created; the activation email has been sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  activation_email_sent: { type: boolean, const: true }
                  license_plate_result:
                    type: [string, "null"]
                    enum: [vehicle_created, link_request_created, null]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: "`duplicate_email`: a user, account or pending invite with this email already exists."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /drivers/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    patch:
      tags: [Drivers]
      operationId: updateDriver
      summary: Update driver profile fields
      description: |
        Updates a limited set of profile fields. Changing `phone` or `email` is
        **not** supported through the API: a phone change triggers a physical
        card replacement with cost implications and must be done in the
        dashboard (`400 unsupported_field`).

        Requires scope `mutaties:bestuurders`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateDriverRequest" }
      responses:
        "200":
          description: Fields updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  updated_fields:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Drivers]
      operationId: deactivateDriver
      summary: Cancel (deactivate) a driver
      description: |
        Deactivates the driver. If the driver still has active vehicles the
        request is refused with `409 driver_has_active_vehicles` and the list
        of vehicles; repeat with `?cancel_vehicles=true` to cancel those as
        well (cards that were already printed enter the destruction flow and
        the driver receives a destruction request by email).

        Idempotent: deleting an already inactive driver returns 200 with
        `already_inactive: true`. Requires scope `mutaties:bestuurders`.
      parameters:
        - name: cancel_vehicles
          in: query
          schema: { type: string, enum: ["true"] }
      responses:
        "200":
          description: Driver deactivated (or already inactive).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  active: { type: boolean, const: false }
                  vehicles_cancelled: { type: integer }
                  already_inactive: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            `driver_has_active_vehicles` (includes `vehicles: [{id, license_plate}]`)
            or `vehicle_cancellation_failed`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /vehicles:
    get:
      tags: [Vehicles]
      operationId: listVehicles
      summary: List or look up vehicles
      description: |
        `card_status` contains the internal card status value (see the
        `CardStatus` schema) of the active card, or `null` when the vehicle has
        no active card. Requires scope `mutaties:voertuigen`.
      parameters:
        - name: license_plate
          in: query
          description: Exact match; separators and case are ignored.
          schema: { type: string }
        - name: active
          in: query
          schema: { type: string, enum: ["true", "false"] }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Matching vehicles, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
                  vehicles:
                    type: array
                    items: { $ref: "#/components/schemas/Vehicle" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Vehicles]
      operationId: createVehicle
      summary: Register a vehicle (and request its card)
      description: |
        Registers a vehicle by license plate. Brand, model, fuel and colour are
        resolved server-side from the Dutch RDW vehicle register; supplied
        vehicle data is never trusted.

        By default the window card is requested in the same call
        (`request_card: true`). A card requires an assigned driver; the `card`
        object in the response tells whether the card was created and, if not,
        why (`no_driver`, `no_exemption`, `blocked`, `already_active_card`,
        `not_requested`, `failed`). A created card is picked up automatically
        by the nightly production batch.

        Requires scope `mutaties:voertuigen`. Supports `Idempotency-Key`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateVehicleRequest" }
      responses:
        "201":
          description: Vehicle registered.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  license_plate: { type: string }
                  driver_id: { type: [string, "null"], format: uuid }
                  entity_id: { type: [string, "null"], format: uuid }
                  card: { $ref: "#/components/schemas/CardResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: "`duplicate_license_plate`: this plate is already registered for the organisation."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /vehicles/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    patch:
      tags: [Vehicles]
      operationId: changeVehicleDriver
      summary: Change the assigned driver
      description: |
        Assigns a different driver to the vehicle. The physical card carries
        the driver's details, so a real driver change replaces the card: the
        old card enters the destruction flow, a new card is produced, and the
        change counts towards the organisation's card-change allowance
        (`costs` is non-zero once the free allowance in the exemption period is
        used up). Issuing a first card on a vehicle without an active card is
        free (`initial_issue: true`). If the new driver belongs to a different
        entity the vehicle moves along (`entity_changed: true`).

        Requires scope `mutaties:voertuigen`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [driver_id]
              properties:
                driver_id: { type: string, format: uuid }
      responses:
        "200":
          description: "Driver changed (or `unchanged: true` when the driver was already assigned)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  driver_id: { type: string, format: uuid }
                  new_card: { type: boolean }
                  initial_issue: { type: boolean }
                  costs: { type: number, description: Charged card-change fee in euros (0 within the free allowance). }
                  entity_changed: { type: boolean }
                  unchanged: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`vehicle_inactive` or `driver_change_failed`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Vehicles]
      operationId: cancelVehicle
      summary: Cancel a vehicle (immediately or on a future date)
      description: |
        Without parameters the vehicle is cancelled immediately: the active
        card is withdrawn (never sent) or enters the destruction flow (already
        sent; the driver receives a destruction request by email and
        `card_destruction_required` is true). Billing stops according to the
        card state.

        With `?cancel_at=YYYY-MM-DD` (a future date) the cancellation is
        scheduled: the card stays active and billed until that date and the
        platform executes the cancellation overnight
        (`cancelled: false, scheduled_for` in the response).

        Idempotent: cancelling an inactive vehicle returns 200 with
        `already_inactive: true`. Requires scope `mutaties:voertuigen`.
      parameters:
        - name: cancel_at
          in: query
          schema: { type: string, format: date }
      responses:
        "200":
          description: Cancelled, scheduled, or nothing to do.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  cancelled: { type: boolean }
                  card_destruction_required: { type: boolean }
                  scheduled_for: { type: string, format: date }
                  already_inactive: { type: boolean }
                  already_cancelled: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`no_active_card` (scheduling requires an active card) or `cancellation_failed`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /vehicles/{id}/license-plate-change:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    post:
      tags: [Vehicles]
      operationId: changeLicensePlate
      summary: Change the license plate of a vehicle
      description: |
        Replaces the vehicle's license plate. The new plate is validated
        against the RDW register; CROW equipment flags carry over from the old
        vehicle. The old card is replaced or destroyed and a new card is
        produced; `costs` reflects the card-change fee once the free allowance
        is used. `old_card_in_transit` is true when the old card is still with
        the printer or in the mail (a card with the old plate will still
        arrive and must be destroyed on receipt).

        When the new plate is already registered to another vehicle within the
        organisation, explicit confirmation is required: the request returns
        `409 transfer_confirmation_required` (including the current driver's
        name); repeat with `allow_transfer: true` to confirm. For an active
        vehicle this destroys that driver's card.

        Requires scope `mutaties:voertuigen`. Supports `Idempotency-Key`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_license_plate]
              properties:
                new_license_plate: { type: string, example: "AB123C" }
                allow_transfer: { type: boolean, default: false }
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: License plate changed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  old_vehicle_id: { type: string, format: uuid }
                  new_vehicle_id: { type: string, format: uuid }
                  new_license_plate: { type: string }
                  costs: { type: number }
                  new_card: { type: boolean }
                  old_card_in_transit: { type: boolean }
                  transfer:
                    type: [object, "null"]
                    properties:
                      from_driver_name: { type: [string, "null"] }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            `vehicle_inactive`, `vehicle_has_no_driver`, `license_plate_in_use`
            (active card with another organisation),
            `transfer_confirmation_required` (includes `current_driver_name`
            and `vehicle_active`), or `license_plate_change_failed`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key issued by RVVontheffing.nl (`rvv_live_...`)."
  parameters:
    ResourceId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Any unique string (max 255 characters). Repeating a POST with the same
        key within 48 hours replays the original response (header
        `Idempotency-Replayed: true`) instead of executing the mutation again.
        Responses with status 5xx are not cached and may be retried.
      schema: { type: string, maxLength: 255 }
  responses:
    BadRequest:
      description: |
        Structurally invalid request: `invalid_body`, `unknown_field`,
        `missing_field`, `invalid_field`, `unsupported_field`, `empty_body`
        or `invalid_idempotency_key`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: "`invalid_api_key`: missing, malformed, unknown, revoked or expired key, or an IP outside the key's allowlist."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: "`insufficient_scope`: the key lacks the required scope."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: "`driver_not_found` / `vehicle_not_found`: unknown id, or a resource outside the organisation of the key."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UnprocessableEntity:
      description: |
        Semantically invalid input, e.g. `invalid_email`, `invalid_phone`,
        `invalid_license_plate`, `entity_not_found`, `entity_required`,
        `driver_not_found`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: "`rate_limited` (per key; includes `Retry-After`) or `too_many_requests` (too many failed authentication attempts from this IP)."
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
          additionalProperties: true
    Driver:
      type: object
      properties:
        id: { type: string, format: uuid }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string, format: email }
        entity_id: { type: [string, "null"], format: uuid }
        active: { type: boolean }
        created_at: { type: string, format: date-time }
    Address:
      type: object
      description: Dutch postal address of the driver; used for card delivery where applicable.
      properties:
        postcode: { type: string, example: "1234 AB" }
        house_number: { type: string }
        addition: { type: string }
        street: { type: string }
        city: { type: string }
    CreateDriverRequest:
      type: object
      required: [first_name, last_name, email, phone]
      properties:
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string, format: email }
        phone: { type: string, description: Dutch phone number (10 digits), example: "0612345678" }
        entity_id:
          type: [string, "null"]
          format: uuid
          description: Required for organisations configured as entity-mandatory.
        address: { $ref: "#/components/schemas/Address" }
        wiu_96a_obtained_on: { type: [string, "null"], format: date }
        wiu_96b_obtained_on: { type: [string, "null"], format: date }
        toolbox_received: { type: boolean }
        license_plate:
          type: [string, "null"]
          description: Optionally register a vehicle for this driver in the same call.
        crow_work_traffic_sign: { type: boolean }
        crow_beacon_light: { type: boolean }
        crow_traffic_cones: { type: boolean }
      additionalProperties: false
    UpdateDriverRequest:
      type: object
      description: At least one field is required. `phone` and `email` are rejected (`unsupported_field`).
      properties:
        first_name: { type: string }
        last_name: { type: string }
        address: { $ref: "#/components/schemas/Address" }
        wiu_96a_obtained_on: { type: [string, "null"], format: date }
        wiu_96b_obtained_on: { type: [string, "null"], format: date }
        wiu_96a_not_applicable: { type: boolean }
        toolbox_received: { type: boolean }
      additionalProperties: false
    CreateVehicleRequest:
      type: object
      required: [license_plate]
      properties:
        license_plate: { type: string, description: Separators and case are ignored., example: "VDN27H" }
        driver_id: { type: [string, "null"], format: uuid }
        entity_id:
          type: [string, "null"]
          format: uuid
          description: Required for organisations configured as entity-mandatory.
        crow_work_traffic_sign: { type: boolean, default: false }
        crow_beacon_light: { type: boolean, default: false }
        crow_traffic_cones: { type: boolean, default: false }
        request_card: { type: boolean, default: true }
      additionalProperties: false
    Vehicle:
      type: object
      properties:
        id: { type: string, format: uuid }
        license_plate: { type: string }
        brand: { type: [string, "null"] }
        model: { type: [string, "null"] }
        entity_id: { type: [string, "null"], format: uuid }
        driver_id: { type: [string, "null"], format: uuid }
        driver_name: { type: [string, "null"] }
        active: { type: boolean }
        card_status: { $ref: "#/components/schemas/CardStatus" }
        created_at: { type: string, format: date-time }
    CardResult:
      oneOf:
        - type: object
          required: [created, card_id, status]
          properties:
            created: { type: boolean, const: true }
            card_id: { type: string, format: uuid }
            status:
              type: string
              enum: [nieuw, wacht_op_gegevensbevestiging]
              description: |
                `nieuw`: queued for the nightly production batch.
                `wacht_op_gegevensbevestiging`: the driver first receives an
                email to confirm the delivery address; production starts after
                confirmation.
        - type: object
          required: [created, reason]
          properties:
            created: { type: boolean, const: false }
            reason:
              type: string
              enum: [not_requested, no_driver, no_exemption, blocked, already_active_card, failed]
    CardStatus:
      type: [string, "null"]
      description: |
        Internal card status values (Dutch platform enum, stable identifiers):
        production pipeline (`wacht_op_gegevensbevestiging`, `wacht_op_betaling`,
        `concept`, `nieuw`, `beoordelen`, `verzonden_naar_drukker`), in the field
        (`verzonden_naar_bestuurder`, `ontvangst_bevestigd`), exceptions
        (`niet_ontvangen`, `drukfout`, `verloren`, `probleem_gemeld`,
        `onbestelbaar`), end of life (`vernietigen`, `vernietigd`,
        `vernietigd_niet_bevestigd`, `vervangen`, `verlopen`, `ingetrokken`,
        `niet_verlengd`). `null` when the vehicle has no active card.
