openapi: 3.1.0
info:
  title: Workel Public API
  version: "1"
  description: >-
    Workspace-scoped REST surface at `/api/public/v1` for customer-held
    workspace API keys (`WorkspaceApiKey`). Every endpoint documented here is
    generated from the routes actually registered under that prefix, and an
    automated parity test fails our build the moment this document and the
    live route table drift apart.


    ## Authentication

    Bearer token in the `Authorization` header: `Authorization: Bearer
    wk_<secret>`. The token is minted by an Owner/Admin from the workspace's
    Settings → Developers tab (session-plane only — a public API key can
    never mint, rotate, or revoke keys or webhooks itself). The guard
    authenticates as the key's **creator** (a real Workel user) for
    attribution purposes; every write made through this API is recorded and
    notified as if that person made it. If the key's creator loses
    owner/admin membership in the workspace, the key stops working (within
    ~60 seconds) even though it has not been explicitly revoked.


    ## Envelope conventions

    - **Errors** are always `{"error": {"type", "code", "message", "param"?,
      "request_id"}}` — see `ErrorEnvelope` below. Never a bare Laravel
      validation body, never a raw exception message.
    - **Paginated lists** are cursor-paginated: `{"data": [...], "meta":
      {"next_cursor"}}`. There is no offset/page pagination on this surface
      — polling a listing under concurrent writes with offset pagination
      skips or duplicates rows, which cursor pagination avoids.
    - **Single-resource reads and writes** are wrapped exactly once:
      `{"data": {...}}` (the Laravel `JsonResource` default). `GET /me` is
      the one exception — it returns `{"workspace", "key", "rate_limit"}` at
      the top level, deliberately unwrapped.
    - Every response, success or error, carries an `X-Request-Id` header —
      either echoed back from a request-supplied `X-Request-Id`, or a
      freshly generated UUID. Quote it in support requests.


    ## Rate limiting

    Two limiters apply to every authenticated request, keyed on the API
    key's own id and on its workspace id (never on IP — a key can be called
    from anywhere). Write requests (`POST`, `PATCH`) additionally consume a
    third, tighter per-key write budget, so a runaway create loop is bounded
    long before it can exhaust the read budget; a write therefore spends one
    unit of each of the three. `GET /me` reports all three. On a successful response, `X-RateLimit-Limit` and
    `X-RateLimit-Remaining` reflect whichever of the two limiters was
    evaluated last in the pipeline (the per-workspace limiter) — this is a
    property of how the underlying rate-limit middleware merges headers
    when a route is guarded by more than one named limit, not something
    this API chooses per response. On a 429, the headers and the error body
    both describe whichever limiter was actually exceeded (`key_rate_limited`,
    `workspace_rate_limited` or `write_rate_limited`). Requests with no valid key at all (or a
    disabled one) are rate-limited separately, by IP.


    ## Idempotency

    Every `POST` under this prefix (never `PATCH`) honors an optional
    `Idempotency-Key` request header. The key is scoped to the calling API
    key — the same header value from a different key can never collide with
    or read back this key's stored response. A repeated request with the
    same key **and** an identical request body replays the original
    response verbatim, adding `Idempotent-Replay: true`; the same key with a
    **different** body is rejected with `409 idempotency_key_reuse`. Only a
    response that actually succeeded (status < 300) is stored — a failed
    attempt leaves nothing cached, so retrying with the same key genuinely
    re-runs the work.


    ## Outbound webhooks

    See the top-level `webhooks` section of this document for the seven
    event types this workspace can subscribe endpoints to, the
    `Workel-Signature` verification scheme, and the delivery/retry contract.
    Webhook endpoints themselves are managed from the Developers tab
    (session-plane only, not part of this REST surface) — there is no
    `/api/public/v1` path for registering one.
  contact:
    name: Workel
externalDocs:
  description: Developer guides, webhook verification, and key-leak response
  url: https://developer.workel.com
servers:
  - url: https://api.workel.com
    description: Production. Custom domain on the backend App Service (DNS-only through Cloudflare, Azure-managed TLS).

security:
  - ApiKeyAuth: []

tags:
  - name: Meta
    description: Key/workspace introspection.
  - name: Projects
    description: Read-only. Never includes inbox, archived, or private projects.
  - name: Members
    description: Active workspace members. The one endpoint on this surface that exposes email addresses.
  - name: Tasks
    description: Read and write. Reachable only through a visible project's board.
  - name: Task comments
    description: Read and write. Plain text only — no @-mention syntax in v1.
  - name: Events
    description: Read and write. Workspace-level and project-level calendar events.

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: wk_<sanctum-token>
      description: >-
        A workspace API key's plaintext secret, `wk_`-prefixed. Shown once at
        mint/rotate time. Bearer-only — there is no session/cookie fallback
        on this surface.

  parameters:
    LimitParam:
      name: limit
      in: query
      required: false
      description: >-
        Page size. Values ≤ 0 fall back to the default; values above the
        maximum are silently clamped, never rejected with a 422. Default 25,
        maximum 100 — both launch placeholders pending ratification, not a
        contract value.
      schema:
        type: integer
        default: 25
        minimum: 1
        maximum: 100
    CursorParam:
      name: cursor
      in: query
      required: false
      description: >-
        Opaque pagination cursor, taken verbatim from a previous response's
        `meta.next_cursor`. Never construct one by hand.
      schema:
        type: string
    IdPathParam:
      name: id
      in: path
      required: true
      description: The resource's id.
      schema:
        type: string
    IdempotencyKeyHeader:
      name: Idempotency-Key
      in: header
      required: false
      description: >-
        Caller-chosen opaque token. See the "Idempotency" section of this
        document's description for the full contract. Stored responses
        expire after 24 hours — a launch placeholder pending ratification,
        not a contract value.
      schema:
        type: string
        maxLength: 255
    WebhookSignatureHeader:
      name: Workel-Signature
      in: header
      required: true
      description: >-
        `t={unix_timestamp},v1={hmac_sha256_hex}` — one `t=` entry followed
        by one `v1=` entry per currently-active signing secret (normally
        one; two during the 24-hour overlap window right after a secret
        rotation, one signed with each secret). Each `v1` value is
        `HMAC-SHA256("{t}.{exact raw request body bytes}", secret)`, hex
        encoded. To verify: parse out `t` and every `v1`, reject if
        `|now - t|` exceeds a 5-minute tolerance, then recompute the HMAC
        over `"{t}.{raw body}"` with your endpoint's own secret and compare
        (constant-time) against each `v1` value — a match against ANY one
        of them is a valid signature. Verify against the exact bytes
        received; re-serializing the parsed JSON before verifying can
        change whitespace and break the comparison.
      schema:
        type: string
        examples:
          - "t=1700000000,v1=5257a869e7bfb..."
    WebhookEventIdHeader:
      name: Workel-Event-Id
      in: header
      required: true
      description: >-
        Stable id for this occurrence (`evt_<uuid>`), identical to the
        payload's own `id` field. Every subscribed endpoint receiving the
        same occurrence gets the same id — use it to deduplicate retried
        and multiply-delivered webhooks.
      schema:
        type: string
    WebhookEventTypeHeader:
      name: Workel-Event-Type
      in: header
      required: true
      description: One of the seven event types below. Identical to the payload's own `type` field.
      schema:
        type: string

  headers:
    XRequestId:
      description: >-
        Echoed from the request's own `X-Request-Id` header if it sent one,
        otherwise a freshly generated UUID. Identical on the response body's
        `error.request_id` for error responses.
      schema:
        type: string
        format: uuid
    XRateLimitLimit:
      description: >-
        The rate limit currently in force for this request (see the
        "Rate limiting" section of this document's description for which of
        the two limiters this reflects).
      schema:
        type: integer
    XRateLimitRemaining:
      description: Requests remaining in the current window for that same limiter.
      schema:
        type: integer
    RetryAfter:
      description: Seconds to wait before retrying. Present only on 429 responses.
      schema:
        type: integer
    IdempotentReplay:
      description: >-
        Present and set to the literal string `true` only when this response
        is a stored replay of a prior identical request under the same
        `Idempotency-Key` — the request was not re-run.
      schema:
        type: string
        enum: ["true"]

  responses:
    Unauthorized:
      description: >-
        No valid API key was presented (missing bearer token, malformed
        secret, unknown/expired token, or a key that failed its creator's
        active-owner/admin recheck).
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemaining"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            missing_key:
              value:
                error:
                  type: authentication_error
                  code: api_key_required
                  message: A workspace API key is required.
                  request_id: 8f14e45f-ceea-4a2e-8c9e-4e5b2d5d0f1a
    Forbidden:
      description: >-
        The key is valid but disabled, or lacks the scope this operation
        requires, or (for `POST /tasks`) the key's acting user lacks
        permission to create tasks in the target project.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemaining"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            scope_required:
              value:
                error:
                  type: permission_error
                  code: scope_required
                  message: "This API key is not allowed to: read:tasks"
                  param: scope
                  request_id: 8f14e45f-ceea-4a2e-8c9e-4e5b2d5d0f1a
    NotFound:
      description: >-
        The id does not resolve to a visible resource. This is
        intentionally indistinguishable from "exists but belongs to another
        workspace", "exists but is private/archived/inbox", and "does not
        exist at all" — the API never confirms the existence of a resource
        the key cannot see.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemaining"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            not_found:
              value:
                error:
                  type: invalid_request_error
                  code: not_found
                  message: Resource not found.
                  request_id: 8f14e45f-ceea-4a2e-8c9e-4e5b2d5d0f1a
    ValidationError:
      description: >-
        The request body or query string failed validation. `error.param`
        names the first field that failed — only the first failing field is
        reported, even if several are invalid.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemaining"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            validation_error:
              value:
                error:
                  type: invalid_request_error
                  code: validation_error
                  message: The title text field is required.
                  param: title_text
                  request_id: 8f14e45f-ceea-4a2e-8c9e-4e5b2d5d0f1a
    RateLimited:
      description: >-
        A limiter was exceeded: per-key, per-workspace, the tighter per-key
        write budget (`POST`/`PATCH` only), or — for an unauthenticated or
        disabled key — the per-IP one. `error.code` names which.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemaining"
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            key_rate_limited:
              value:
                error:
                  type: rate_limit_error
                  code: key_rate_limited
                  message: Too many requests for this API key.
                  request_id: 8f14e45f-ceea-4a2e-8c9e-4e5b2d5d0f1a
    IdempotencyConflict:
      description: >-
        POST only. The `Idempotency-Key` header was already used, with a
        request body that hashes differently from this one.
      headers:
        X-Request-Id:
          $ref: "#/components/headers/XRequestId"
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemaining"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
          examples:
            idempotency_key_reuse:
              value:
                error:
                  type: idempotency_error
                  code: idempotency_key_reuse
                  message: This Idempotency-Key was already used with a different request body.
                  request_id: 8f14e45f-ceea-4a2e-8c9e-4e5b2d5d0f1a

  requestBodies:
    WebhookDelivery:
      description: >-
        A signed webhook delivery. Retried up to 5 times total (1m, 5m,
        30m, then 2h between attempts — launch placeholders pending
        ratification, not a contract value) on any non-2xx response or
        transport error; a 3xx redirect is treated as a failure and is
        never followed. After 20 consecutive exhausted deliveries (same
        placeholder caveat) the endpoint is auto-disabled and must be
        re-enabled from the Developers tab.
      required: true
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/WebhookDeliveryEnvelope"

  schemas:
    WebhookDeliveryEnvelope:
      type: object
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          description: Identical to the `Workel-Event-Id` header.
        type:
          type: string
          description: Identical to the `Workel-Event-Type` header. One of the seven event types documented under `webhooks` below.
        created_at:
          type: string
          format: date-time
        data:
          type: object
          description: >-
            The same minimal-disclosure resource shape the matching read
            endpoint returns, with free-text fields removed (task/project/
            event descriptions, comment bodies) — a compromised endpoint
            receives identifiers and safe fields only; fetch full content
            with your own scoped key. `task.deleted` is the one exception:
            an ids-only shape (`id`, `card_id`, `project_id`), not derived
            from any read resource.

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message, request_id]
          properties:
            type:
              type: string
              description: >-
                The broad error category. One of: `authentication_error`
                (401), `permission_error` (403), `invalid_request_error`
                (404 and 422 both — a not-found id and a bad field share this
                type), `rate_limit_error` (429), `idempotency_error` (409).
              examples: [invalid_request_error]
            code:
              type: string
              description: >-
                A stable machine-readable code, more specific than `type`.
                Known values include (non-exhaustive — new codes may be
                added without a version bump): `api_key_required`,
                `api_key_disabled`, `scope_required`, `scope_not_asserted`,
                `validation_error`, `not_found`, `no_open_column`,
                `inbox_project`, `invalid_assignee`, `invalid_date`,
                `window_too_large`, `forbidden`, `idempotency_key_reuse`,
                `key_rate_limited`, `workspace_rate_limited`,
                `write_rate_limited`, `anonymous_rate_limited`.
              examples: [not_found]
            message:
              type: string
              description: A human-readable explanation. Not intended for stable string matching — match on `code`.
            param:
              type: string
              description: The request field this error concerns, when applicable (validation errors, scope errors).
            request_id:
              type: string
              format: uuid
              description: Identical to the response's `X-Request-Id` header.

    CursorMeta:
      type: object
      required: [next_cursor]
      properties:
        next_cursor:
          type: [string, "null"]
          description: Pass verbatim as `?cursor=` to fetch the next page. Null on the last page.

    Project:
      type: object
      required: [id, name, description, created_at, updated_at]
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: [string, "null"]
        created_at:
          type: [string, "null"]
          format: date-time
        updated_at:
          type: [string, "null"]
          format: date-time

    Card:
      type: object
      description: A board column (kanban list, e.g. "To Do"/"In Progress") — not a task, despite the table name.
      required: [id, name, is_done, order]
      properties:
        id:
          type: string
        name:
          type: string
        is_done:
          type: boolean
        order:
          type: [integer, "null"]

    TaskCardSummary:
      type: [object, "null"]
      required: [id, name, is_done]
      properties:
        id:
          type: string
        name:
          type: string
        is_done:
          type: boolean

    Task:
      type: object
      required:
        - id
        - title
        - description
        - project_id
        - card
        - priority
        - due_date
        - due_time
        - progress
        - completed
        - assignee_ids
        - created_at
        - updated_at
      properties:
        id:
          type: string
        title:
          type: string
          description: Reads the task's `title_text` column. Empty string, never null, if the task somehow has no title.
        description:
          type: [string, "null"]
        project_id:
          type: [string, "null"]
        card:
          $ref: "#/components/schemas/TaskCardSummary"
        priority:
          type: [string, "null"]
          description: >-
            Freeform on read (whatever is stored) — write endpoints
            constrain new values to `low`, `medium`, `high`, `urgent`,
            `none`.
        due_date:
          type: [string, "null"]
          format: date
          description: The date portion of `end_date`, or null if `end_date` is empty or not shaped like a date.
        due_time:
          type: [string, "null"]
          description: '`end_time` verbatim, e.g. "14:30".'
        progress:
          type: [integer, "null"]
          minimum: 0
          maximum: 100
        completed:
          type: boolean
          description: 'True exactly when `progress === 100`. A null progress is not completed.'
        assignee_ids:
          type: array
          items:
            type: string
          description: User ids — the same ids `GET /members` returns.
        created_at:
          type: [string, "null"]
          format: date-time
        updated_at:
          type: [string, "null"]
          format: date-time

    TaskCommentAuthor:
      type: [object, "null"]
      required: [id, name]
      properties:
        id:
          type: string
        name:
          type: string

    TaskComment:
      type: object
      required: [id, body, author, created_at]
      properties:
        id:
          type: string
        body:
          type: string
        author:
          $ref: "#/components/schemas/TaskCommentAuthor"
        created_at:
          type: [string, "null"]
          format: date-time

    Event:
      type: object
      required:
        - id
        - title
        - description
        - date
        - start_time
        - end_time
        - location
        - project_id
        - workspace_id
        - color
        - repeat
        - created_at
        - updated_at
      properties:
        id:
          type: string
        title:
          type: string
        description:
          type: [string, "null"]
        date:
          type: [string, "null"]
          format: date
        start_time:
          type: [string, "null"]
        end_time:
          type: [string, "null"]
        location:
          type: [string, "null"]
        project_id:
          type: [string, "null"]
          description: Exactly one of `project_id`/`workspace_id` is set, never both, never neither.
        workspace_id:
          type: [string, "null"]
        color:
          type: [string, "null"]
        repeat:
          type: [string, "null"]
          enum: [none, daily, weekly, monthly, yearly, null]
        created_at:
          type: [string, "null"]
          format: date-time
        updated_at:
          type: [string, "null"]
          format: date-time

    Member:
      type: object
      description: >-
        The one shape on this surface that includes an email address — a
        deliberate, scoped exception to the minimal-disclosure convention
        everywhere else, gated behind the `read:members` scope.
      required: [id, name, email, role, joined_at]
      properties:
        id:
          type: string
          description: The member's own user id — directly usable as an entry in a task's `assignee_ids`.
        name:
          type: string
        email:
          type: string
          format: email
        role:
          type: [string, "null"]
          enum: [owner, admin, member, null]
        joined_at:
          type: [string, "null"]
          format: date-time

    Me:
      type: object
      required: [workspace, key, rate_limit]
      properties:
        workspace:
          type: object
          required: [id, name]
          properties:
            id:
              type: string
            name:
              type: string
        key:
          type: object
          required: [name, scopes]
          properties:
            name:
              type: string
            scopes:
              type: array
              items:
                $ref: "#/components/schemas/Scope"
        rate_limit:
          type: object
          required: [key, workspace, write]
          properties:
            key:
              $ref: "#/components/schemas/RateLimitState"
            workspace:
              $ref: "#/components/schemas/RateLimitState"
            write:
              allOf:
                - $ref: "#/components/schemas/RateLimitState"
                - description: >-
                    Applies to POST and PATCH only, and is consumed IN ADDITION
                    to the key and workspace budgets — a write spends one of
                    each. Keyed on the API key.

    RateLimitState:
      type: object
      required: [limit, remaining]
      properties:
        limit:
          type: integer
          description: Launch placeholder pending ratification — not a contract value.
        remaining:
          type: integer

    Scope:
      type: string
      enum:
        - read:projects
        - read:tasks
        - read:events
        - read:members
        - write:tasks
        - write:events
        - write:comments

    StorePublicTaskRequest:
      type: object
      required: [title_text]
      properties:
        card_id:
          type: [string, "null"]
          description: >-
            The board column to create the task in. Exactly one of
            `card_id`/`project_id` should be given; when `card_id` is
            omitted, `project_id` is required and the task lands in that
            project's first non-done column (422 `no_open_column` if none
            exists).
        project_id:
          type: [string, "null"]
        title_text:
          type: string
          maxLength: 500
        description:
          type: [string, "null"]
          maxLength: 20000
        priority:
          type: [string, "null"]
          enum: [low, medium, high, urgent, none, null]
        progress:
          type: [integer, "null"]
          minimum: 0
          maximum: 100
        end_date:
          type: [string, "null"]
          format: date
        end_time:
          type: [string, "null"]
          description: '"HH:mm". Dropped if `end_date` is not also given.'
        user_ids:
          type: [array, "null"]
          items:
            type: string
          description: Must all be members of the bound workspace, or the whole request is rejected with 422 `invalid_assignee`.

    UpdatePublicTaskRequest:
      type: object
      description: >-
        PATCH semantics: every field is optional. An omitted key leaves the
        column untouched; an explicit `null` on a nullable field clears it
        (e.g. `end_date: null` also drops `end_time`). Assignees and
        project/card moves cannot be changed through this endpoint.
      properties:
        title_text:
          type: [string, "null"]
          maxLength: 500
        description:
          type: [string, "null"]
          maxLength: 20000
        priority:
          type: [string, "null"]
          enum: [low, medium, high, urgent, none, null]
        progress:
          type: [integer, "null"]
          minimum: 0
          maximum: 100
        end_date:
          type: [string, "null"]
          format: date
        end_time:
          type: [string, "null"]
        reminder_date:
          type: [string, "null"]
          format: date-time

    StorePublicTaskCommentRequest:
      type: object
      required: [body]
      properties:
        body:
          type: string
          maxLength: 10000
        mention_user_ids:
          description: >-
            Must not be sent at all — including as an empty array or `null`
            — or the request is rejected with 422. No @-mention syntax is
            parsed in v1.
          not: {}

    StorePublicEventRequest:
      type: object
      required: [title, date, start_time, end_time, repeat]
      properties:
        title:
          type: string
          maxLength: 255
        description:
          type: [string, "null"]
        date:
          type: string
          format: date
        start_time:
          type: string
          description: '"HH:mm".'
        end_time:
          type: string
          description: '"HH:mm", strictly after start_time.'
        reminder_at:
          type: [string, "null"]
          format: date-time
        timezone:
          type: [string, "null"]
          description: A valid IANA timezone name.
        reminder_minutes_before:
          type: [integer, "null"]
          minimum: 1
          maximum: 10080
        repeat:
          type: string
          enum: [none, daily, weekly, monthly, yearly]
        repeat_interval:
          type: [integer, "null"]
          minimum: 1
          maximum: 365
          description: Required unless `repeat` is `none`.
        location:
          type: [string, "null"]
        meet_link:
          type: [string, "null"]
          format: uri
        color:
          type: [string, "null"]
          maxLength: 7
        order:
          type: [integer, "null"]
        project_id:
          type: [string, "null"]
          description: >-
            When given, must resolve to a project visible in the bound
            workspace (not inbox, not archived, not private) or the request
            is rejected with 422 — a foreign, private, inbox, or nonexistent
            id are all indistinguishable. Omitted → the event is
            workspace-level.
        invited_users:
          type: [array, "null"]
          items:
            type: string
          description: >-
            User ids to invite. Existence is checked; an id outside the
            bound workspace's membership is silently dropped rather than
            rejected (unlike a task's `user_ids`).

paths:
  /api/public/v1/me:
    get:
      operationId: getMe
      tags: [Meta]
      summary: Confirm a key is working and inspect its scopes and rate-limit state
      description: Requires a valid key but no specific scope.
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Me"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/public/v1/projects:
    get:
      operationId: listProjects
      tags: [Projects]
      summary: List visible projects
      description: Excludes inbox projects, archived projects, and private projects.
      parameters:
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Project"
                  meta:
                    $ref: "#/components/schemas/CursorMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/public/v1/projects/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPathParam"
    get:
      operationId: getProject
      tags: [Projects]
      summary: Fetch a single visible project
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/public/v1/projects/{id}/cards:
    parameters:
      - $ref: "#/components/parameters/IdPathParam"
    get:
      operationId: listProjectCards
      tags: [Projects]
      summary: List a project's board columns
      description: >-
        A Workel "card" here is a board COLUMN (e.g. "To Do"/"In Progress"),
        not a task — so integrators can target a column id on task create.
      parameters:
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Card"
                  meta:
                    $ref: "#/components/schemas/CursorMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/public/v1/members:
    get:
      operationId: listMembers
      tags: [Members]
      summary: List active workspace members
      description: Only members with an active (not pending/suspended) membership are listed.
      parameters:
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Member"
                  meta:
                    $ref: "#/components/schemas/CursorMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/public/v1/tasks:
    get:
      operationId: listTasks
      tags: [Tasks]
      summary: List tasks across every visible project
      parameters:
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
        - name: project_id
          in: query
          required: false
          description: Narrow to one project. A foreign/private/inbox/nonexistent id simply yields zero rows.
          schema:
            type: string
        - name: card_id
          in: query
          required: false
          description: Narrow to one board column.
          schema:
            type: string
        - name: completed
          in: query
          required: false
          description: >-
            `true` for progress===100, `false` for progress<100 or null
            (never-progressed). Any other value is silently ignored — the
            filter is simply not applied, never a 422.
          schema:
            type: string
            enum: [true, "1", "false", "0"]
        - name: due_before
          in: query
          required: false
          description: 'Strict `YYYY-MM-DD`. A value not shaped like this is silently ignored.'
          schema:
            type: string
            format: date
        - name: due_after
          in: query
          required: false
          description: 'Strict `YYYY-MM-DD`. A value not shaped like this is silently ignored.'
          schema:
            type: string
            format: date
        - name: updated_since
          in: query
          required: false
          description: Any parseable date/time string. Unparseable values are silently ignored.
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Task"
                  meta:
                    $ref: "#/components/schemas/CursorMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      operationId: createTask
      tags: [Tasks]
      summary: Create a task
      description: >-
        Runs the full task-create recipe (tenant re-check, permission check
        against the target project, default-column resolution, assignee
        membership re-verification, activity/notification dispatch, realtime
        broadcast) — not a thinned-down write.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StorePublicTaskRequest"
      responses:
        "201":
          description: Created
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Task"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            Missing `write:tasks` scope, OR the key's acting user lacks
            `TASK_CREATE` permission in the target project.
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "404":
          description: '`card_id`/`project_id` does not resolve to a visible, writable project — same collapsing rule as every other read on this surface.'
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "409":
          $ref: "#/components/responses/IdempotencyConflict"
        "422":
          description: 'Validation failure, OR the target project is an inbox project (`inbox_project`), OR it has no open column (`no_open_column`), OR one or more `user_ids` are not members of this workspace (`invalid_assignee`).'
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/public/v1/tasks/{id}:
    parameters:
      - $ref: "#/components/parameters/IdPathParam"
    get:
      operationId: getTask
      tags: [Tasks]
      summary: Fetch a single task
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Task"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    patch:
      operationId: updateTask
      tags: [Tasks]
      summary: Update a task
      description: >-
        NOT idempotency-key aware (`PublicApiIdempotency` only inspects
        `POST` requests) — a retried PATCH with the same body is safe only
        because the writable fields are themselves idempotent, not because
        of Idempotency-Key support. Every accepted field is applied through
        the same `TaskUpdateService` the web UI's task-update path uses, so
        the reminder re-arm, orphaned-`end_time` cleanup, and
        recurring-task roll-forward invariants apply identically here.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdatePublicTaskRequest"
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Task"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/public/v1/tasks/{id}/comments:
    parameters:
      - $ref: "#/components/parameters/IdPathParam"
    get:
      operationId: listTaskComments
      tags: [Task comments]
      summary: List a task's comments
      description: >-
        Every comment on the task, top-level and replies alike, oldest
        first. Exists mainly so a `comment.created` webhook consumer (a
        thin, ids-only payload) can fetch the comment it was told about.
      parameters:
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/TaskComment"
                  meta:
                    $ref: "#/components/schemas/CursorMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      operationId: createTaskComment
      tags: [Task comments]
      summary: Comment on a task
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StorePublicTaskCommentRequest"
      responses:
        "201":
          description: Created
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/TaskComment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/IdempotencyConflict"
        "422":
          description: 'Validation failure — including sending `mention_user_ids` at all (@-mentions are not parsed in v1; the field is `prohibited`, not silently ignored).'
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/public/v1/events:
    get:
      operationId: listEvents
      tags: [Events]
      summary: List workspace and project events
      description: Includes events bound directly to the workspace and events on any visible project.
      parameters:
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/CursorParam"
        - name: from
          in: query
          required: false
          description: Inclusive lower bound. Any parseable date. Unparseable → 422 `invalid_date`.
          schema:
            type: string
            format: date
        - name: to
          in: query
          required: false
          description: >-
            Inclusive upper bound. Any parseable date. Unparseable → 422
            `invalid_date`. Combined with `from`, the window may not exceed
            400 days — a wider window is a hard 422 `window_too_large`,
            never silently clamped.
          schema:
            type: string
            format: date
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Event"
                  meta:
                    $ref: "#/components/schemas/CursorMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      operationId: createEvent
      tags: [Events]
      summary: Create an event
      description: >-
        Unconditionally logged to the workspace/project activity feed on
        success, even with zero invitees — an API-originated write is
        exactly the case this audit trail exists for.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StorePublicEventRequest"
      responses:
        "201":
          description: Created
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            Idempotent-Replay:
              $ref: "#/components/headers/IdempotentReplay"
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Event"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/IdempotencyConflict"
        "422":
          description: 'Validation failure, including an unresolvable `project_id` (foreign, private, inbox, and nonexistent all collapse to this — no existence oracle).'
          headers:
            X-Request-Id:
              $ref: "#/components/headers/XRequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimited"

webhooks:
  taskCreated:
    post:
      operationId: webhookTaskCreated
      summary: task.created
      description: >-
        Fired from the `Task` model's `created` Eloquent hook — every
        origin (this API, the web UI, an import), not only writes made
        through this API. Suppressed for the duration of a bulk CSV import.
      parameters:
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookEventTypeHeader"
      requestBody:
        $ref: "#/components/requestBodies/WebhookDelivery"
      responses:
        "200":
          description: Your endpoint should return any 2xx status. Anything else is treated as a failed delivery and retried.
  taskUpdated:
    post:
      operationId: webhookTaskUpdated
      summary: task.updated
      description: >-
        Fired from the shared task-update service (the same seam
        `PATCH /tasks/{id}` and the web UI's task editor both go through)
        whenever a field actually changes. A plain column-move drag in the
        web UI (no field edit) does NOT emit this — a known v1 limitation.
      parameters:
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookEventTypeHeader"
      requestBody:
        $ref: "#/components/requestBodies/WebhookDelivery"
      responses:
        "200":
          description: Your endpoint should return any 2xx status. Anything else is treated as a failed delivery and retried.
  taskCompleted:
    post:
      operationId: webhookTaskCompleted
      summary: task.completed
      description: Fired from the same seam as `task.updated`, specifically on the progress <100 → ≥100 transition.
      parameters:
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookEventTypeHeader"
      requestBody:
        $ref: "#/components/requestBodies/WebhookDelivery"
      responses:
        "200":
          description: Your endpoint should return any 2xx status. Anything else is treated as a failed delivery and retried.
  taskDeleted:
    post:
      operationId: webhookTaskDeleted
      summary: task.deleted
      description: >-
        Ids-only payload (`id`, `card_id`, `project_id`) — deliberately not
        built from `TaskResource`, since a deleted task's title/description
        are exactly what a mirror-keeping consumer does not need.
      parameters:
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookEventTypeHeader"
      requestBody:
        $ref: "#/components/requestBodies/WebhookDelivery"
      responses:
        "200":
          description: Your endpoint should return any 2xx status. Anything else is treated as a failed delivery and retried.
  commentCreated:
    post:
      operationId: webhookCommentCreated
      summary: comment.created
      description: '`body` is stripped from the payload (free-text fields are never sent over webhooks) — fetch the full comment with `GET /tasks/{id}/comments` using your own scoped key.'
      parameters:
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookEventTypeHeader"
      requestBody:
        $ref: "#/components/requestBodies/WebhookDelivery"
      responses:
        "200":
          description: Your endpoint should return any 2xx status. Anything else is treated as a failed delivery and retried.
  eventCreated:
    post:
      operationId: webhookEventCreated
      summary: event.created
      parameters:
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookEventTypeHeader"
      requestBody:
        $ref: "#/components/requestBodies/WebhookDelivery"
      responses:
        "200":
          description: Your endpoint should return any 2xx status. Anything else is treated as a failed delivery and retried.
  projectCreated:
    post:
      operationId: webhookProjectCreated
      summary: project.created
      parameters:
        - $ref: "#/components/parameters/WebhookSignatureHeader"
        - $ref: "#/components/parameters/WebhookEventIdHeader"
        - $ref: "#/components/parameters/WebhookEventTypeHeader"
      requestBody:
        $ref: "#/components/requestBodies/WebhookDelivery"
      responses:
        "200":
          description: Your endpoint should return any 2xx status. Anything else is treated as a failed delivery and retried.
