# Meerstaat Public API, OpenAPI 3.1.
# Gegenereerd uit docs/API.md door apps/landing/scripts/sync-legal.mjs.
# Niet met de hand aanpassen: pas docs/API.md aan.
openapi: 3.1.0
info:
  title: Meerstaat Public API
  version: "1.0.0"
  description: >
    Read a company's meerwerk (extra billable work) and jobs, create jobs, and
    subscribe to webhooks. Authenticate with a per company API key in the
    Authorization header. All list endpoints are cursor paginated and ordered by
    createdAt descending.
  contact:
    name: Meerstaat
    url: https://meerstaat.nl
servers:
  - url: https://app.meerstaat.nl/api
    description: Production
  - url: http://127.0.0.1:5001/{projectId}/europe-west1/api
    description: Firebase emulator
    variables:
      projectId:
        default: meerstaat-dev
security:
  - apiKey: []
tags:
  - name: Entries
    description: Meerwerk posts (bewijsstroken).
  - name: Jobs
    description: Jobs (klussen) that entries are logged against.
paths:
  /v1/entries:
    get:
      tags: [Entries]
      summary: List entries
      description: >
        Returns a company's entries, newest first, cursor paginated. Never reads
        the whole collection.
      parameters:
        - name: limit
          in: query
          required: false
          description: Page size. Default 25, maximum 100.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: cursor
          in: query
          required: false
          description: The id of the last entry from the previous page.
          schema:
            type: string
        - name: status
          in: query
          required: false
          description: Optional status filter.
          schema:
            type: string
            enum: [draft, submitted, approved, rejected, invoiced]
      responses:
        "200":
          description: A page of entries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EntryPage"
              example:
                data:
                  - id: "aZ29fK1pQ7"
                    serial: 1042
                    status: "submitted"
                    jobId: "job_7Yh3"
                    jobName: "Badkamer Jansen, Utrecht"
                    memberUid: "u_9kLm2"
                    memberName: "Peter de Vries"
                    description: "Extra wandcontactdoos geplaatst naast de wastafel."
                    amountCents: 8500
                    photoRefs:
                      - "companies/c_8Fk2/entries/aZ29fK1pQ7/9a1c.webp"
                    thumbRef: "companies/c_8Fk2/entries/aZ29fK1pQ7/thumb_9a1c.webp"
                    geo:
                      lat: 52.0907
                      lng: 5.1214
                      address: "Oudegracht 12, 3511 AA Utrecht"
                    capturedAt: "2026-07-16T09:12:44.000Z"
                    createdAt: "2026-07-16T09:12:46.512Z"
                    updatedAt: "2026-07-16T09:12:46.512Z"
                nextCursor: "aZ29fK1pQ7"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /v1/entries/{id}:
    get:
      tags: [Entries]
      summary: Get one entry
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The entry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Entry"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
  /v1/jobs:
    get:
      tags: [Jobs]
      summary: List active jobs
      description: Returns active jobs, newest first, cursor paginated.
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: cursor
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: A page of jobs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobPage"
              example:
                data:
                  - id: "job_7Yh3"
                    name: "Badkamer Jansen, Utrecht"
                    client: "Familie Jansen"
                    address: "Oudegracht 12, 3511 AA Utrecht"
                    status: "active"
                    open: true
                    entryCount: 3
                    createdAt: "2026-07-01T07:30:00.000Z"
                    lastEntryAt: "2026-07-16T09:12:46.512Z"
                nextCursor: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Jobs]
      summary: Create a job
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JobCreate"
            example:
              name: "Dakkapel Bakker, Amersfoort"
              client: "De heer Bakker"
              address: "Stationsstraat 4, 3811 MJ Amersfoort"
      responses:
        "201":
          description: The created job.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Job"
              example:
                id: "job_9Qp1"
                name: "Dakkapel Bakker, Amersfoort"
                client: "De heer Bakker"
                address: "Stationsstraat 4, 3811 MJ Amersfoort"
                status: "active"
                open: true
                entryCount: 0
                createdAt: "2026-07-16T10:04:11.203Z"
                lastEntryAt: null
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: "mk_<companyId>_<random>"
      description: >
        Per company API key. Format mk_<companyId>_<random>. Only the sha256
        hash of the key is stored server side.
  responses:
    BadRequest:
      description: The request was malformed.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: "invalid_cursor"
              message: "De cursor verwijst niet naar een bestaande post."
    Unauthorized:
      description: Missing, unknown or revoked API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: "unauthorized"
              message: "Ongeldige of ontbrekende API-sleutel."
    NotFound:
      description: The item or endpoint does not exist.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: "not_found"
              message: "Post niet gevonden."
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
            message:
              type: string
              description: Human readable Dutch message.
    Geo:
      type: [object, "null"]
      properties:
        lat:
          type: number
        lng:
          type: number
        address:
          type: [string, "null"]
    Entry:
      type: object
      required:
        [id, serial, status, jobId, jobName, memberUid, memberName,
         description, amountCents, rejectionReason, photoRefs, thumbRef, geo,
         capturedAt, createdAt, updatedAt]
      properties:
        id:
          type: string
        serial:
          type: [integer, "null"]
          description: Per company bewijsstrook serial. Null while offline.
        status:
          type: string
          enum: [draft, submitted, approved, rejected, invoiced]
        jobId:
          type: string
        jobName:
          type: string
          description: Job name denormalized at capture time.
        memberUid:
          type: string
        memberName:
          type: string
          description: Employee name denormalized at capture time.
        description:
          type: string
        amountCents:
          type: [integer, "null"]
          description: Price in euro cents, set by the owner. Null if not priced.
        rejectionReason:
          type: [string, "null"]
          description: >
            Why the owner declined the entry. Non-null only while status is
            rejected; null on every other status, and null again once the
            owner reopens the entry.
        photoRefs:
          type: array
          items:
            type: string
          description: Storage object paths of the original photos.
        thumbRef:
          type: [string, "null"]
          description: Storage path of the 300px thumbnail.
        geo:
          $ref: "#/components/schemas/Geo"
        capturedAt:
          type: [string, "null"]
          format: date-time
        createdAt:
          type: [string, "null"]
          format: date-time
        updatedAt:
          type: [string, "null"]
          format: date-time
    EntryPage:
      type: object
      required: [data, nextCursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Entry"
        nextCursor:
          type: [string, "null"]
    Job:
      type: object
      required:
        [id, name, client, address, status, open, entryCount, createdAt,
         lastEntryAt]
      properties:
        id:
          type: string
        name:
          type: string
        client:
          type: [string, "null"]
        address:
          type: [string, "null"]
        status:
          type: string
          enum: [active, archived]
        open:
          type: boolean
          description: If true, every member of the company sees the job.
        entryCount:
          type: integer
        createdAt:
          type: [string, "null"]
          format: date-time
        lastEntryAt:
          type: [string, "null"]
          format: date-time
    JobPage:
      type: object
      required: [data, nextCursor]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Job"
        nextCursor:
          type: [string, "null"]
    JobCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 120
        client:
          type: string
          maxLength: 200
        address:
          type: string
          maxLength: 500
    WebhookPayload:
      type: object
      required: [id, type, createdAt, companyId, data]
      properties:
        id:
          type: string
          description: Unique delivery id, also sent as X-Meerstaat-Delivery.
        type:
          type: string
          enum: [new.entry, approved.entry, rejected.entry]
        createdAt:
          type: string
          format: date-time
        companyId:
          type: string
        data:
          $ref: "#/components/schemas/Entry"
webhooks:
  entryEvent:
    post:
      summary: Entry event (new.entry, approved.entry or rejected.entry)
      description: >
        Meerstaat POSTs this to every registered endpoint that subscribed to the
        event. Verify the X-Meerstaat-Signature header before trusting the body.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookPayload"
      responses:
        "200":
          description: Acknowledged. Any 2xx is treated as delivered.
