{
  "openapi": "3.1.0",
  "info": {
    "title": "I Want To: widget API",
    "version": "1.0.0",
    "description": "The API the feedback widget talks to: read a project's feed, send feedback, vote, comment and sign in with an email code. Everything the web widget does goes through these routes, so a custom client can do the same.\n\n**Origin.** Every request must come from an origin registered for the project (Widget & install, in the console). Browsers send `Origin` themselves; any other origin, or none, gets `403 origin_not_allowed` or `origin_missing`. A project that does not exist answers the same as an unregistered origin. There is no way for a native app to call this API yet.\n\n**Sign-in.** `auth/code` emails a code, `auth/session` exchanges it for a bearer token. Reading needs no token. A token from one company is not accepted in another company's project.\n\n**Limits.** Every operation can answer `429 rate_limited` with a `Retry-After` header (seconds). Cross-origin responses are never cached.\n\nThe public board pages and their own `/api/v1/site/...` routes, the developer console API and the unsubscribe link are not part of this document. The console API is described in `openapi-console.json`."
  },
  "servers": [
    {
      "url": "https://iwanto.org",
      "description": "Production. Self-hosted: your deployment's PUBLIC_BASE_URL."
    }
  ],
  "tags": [
    {
      "name": "Feed",
      "description": "Read and send feedback."
    },
    {
      "name": "Votes",
      "description": "\"I agree\"."
    },
    {
      "name": "Comments",
      "description": "Comments and the Helpful mark."
    },
    {
      "name": "Sign-in",
      "description": "Email code to bearer token."
    },
    {
      "name": "Profile",
      "description": "The visitor's name and email preferences."
    },
    {
      "name": "Attachments",
      "description": "Files on feedback: images and PDF."
    },
    {
      "name": "Service",
      "description": "Health checks."
    }
  ],
  "paths": {
    "/api/v1/widget/{projectId}/bootstrap": {
      "get": {
        "summary": "Open the widget",
        "tags": [
          "Feed"
        ],
        "description": "The one request a client opens with: project settings, section list, counts per type and the first 20 items of the feed. Reading needs no session; a valid one only fills `myVote`.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {},
          {
            "bearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "The project and its first page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Bootstrap"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/feedback": {
      "get": {
        "summary": "List feedback",
        "tags": [
          "Feed"
        ],
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "Only this type id.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Only this section.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "The order. `trending` counts the last 7 days of votes.",
            "schema": {
              "enum": [
                "trending",
                "score",
                "recent",
                "discussed"
              ],
              "default": "trending"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 20
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "The `nextCursor` of the previous page. Opaque. A cursor that does not fit the request answers `400 invalid_request`.",
            "schema": {
              "type": "string",
              "maxLength": 2048
            }
          }
        ],
        "security": [
          {},
          {
            "bearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "One page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FeedPage"
                }
              }
            }
          },
          "400": {
            "description": "A parameter did not parse.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "summary": "Send feedback",
        "tags": [
          "Feed"
        ],
        "description": "Creates an item as the signed-in visitor and casts their own vote on it. With moderation set to `pre`, the item answers `moderation: \"awaiting\"` and is not public yet.\n\nRate limit: 5 submissions per hour per visitor. A replay with the same `Idempotency-Key` is answered first and does not spend it.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "description": "A client-generated key, 8 to 128 characters of `A-Z a-z 0-9 _ -`. Repeating a request with the same key and the same content returns the first result; the same key with different content answers `422 idempotency_key_reused`.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{8,128}$"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FeedbackInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A replay of an earlier request with the same key.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "created": {
                      "const": false
                    },
                    "moderation": {
                      "enum": [
                        "awaiting",
                        "approved"
                      ]
                    }
                  },
                  "required": [
                    "id",
                    "created",
                    "moderation"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "created": {
                      "const": true
                    },
                    "moderation": {
                      "enum": [
                        "awaiting",
                        "approved"
                      ]
                    }
                  },
                  "required": [
                    "id",
                    "created",
                    "moderation"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The body, a section, a type or the key is wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request",
                        "idempotency_key_required"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No session, an expired one, or one that belongs to another company. All three look the same on purpose.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "unauthenticated"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "The origin is refused, or the project is paused.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "origin_not_allowed",
                        "origin_missing",
                        "project_paused"
                      ]
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "An attachment does not exist.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "attachment_not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "An attachment is not ready, or is already used.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "attachment_not_ready",
                        "attachment_conflict",
                        "attachment_in_use"
                      ]
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "The key was used with different content.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "idempotency_key_reused"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/feedback/similar": {
      "get": {
        "summary": "Find similar items",
        "tags": [
          "Feed"
        ],
        "description": "Up to 20 items that resemble the text (by meaning and by words), for the \"someone may have said this already\" card. Active items come first by votes, then done and declined ones. Hidden, deleted, merged and unmoderated items are never returned.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "title",
            "in": "query",
            "required": true,
            "description": "The text to search for; not blank, at most 500 characters.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 500
            }
          },
          {
            "name": "details",
            "in": "query",
            "required": false,
            "description": "The details text typed so far; optional, at most 2000 characters. Searched together with the title.",
            "schema": {
              "type": "string",
              "maxLength": 2000
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "How many.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 20,
              "default": 5
            }
          }
        ],
        "security": [
          {},
          {
            "bearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "Matches.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/FeedItem"
                      }
                    }
                  },
                  "required": [
                    "items"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "A parameter did not parse.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/campaigns": {
      "get": {
        "summary": "The open campaigns",
        "tags": [
          "Campaigns"
        ],
        "description": "What the widget downloads once per page when the page opted in with the `campaigns` option: the open surveys and announcements, compiled with their targeting and schedule so the browser can decide what to show. Nothing about the visitor is sent. Cacheable for a minute; send `If-None-Match` with the ETag. Needs no session.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {}
        ],
        "responses": {
          "200": {
            "description": "The snapshot.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "v": {
                      "const": 1
                    },
                    "projectId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "etag": {
                      "type": "string"
                    },
                    "generatedAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "settings": {
                      "type": "object",
                      "additionalProperties": true
                    },
                    "campaigns": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "additionalProperties": true
                      }
                    }
                  },
                  "required": [
                    "v",
                    "projectId",
                    "etag",
                    "generatedAt",
                    "settings",
                    "campaigns"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "304": {
            "description": "Unchanged since the ETag."
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "404": {
            "description": "Campaigns are off for this project.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/campaign-events": {
      "post": {
        "summary": "Report campaign events",
        "tags": [
          "Campaigns"
        ],
        "description": "A batch of what the widget saw: `view`, `open`, `answer`, `complete`, `close`, `click`, each with the campaign id (`c`), version id (`v`), type (`t`) and optionally the page id (`p`). At most 20. `vid` is the visitor cookie id when there is one. Send it as text/plain with `keepalive` so nothing is preflighted. Unknown campaigns are dropped. Needs no session.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {}
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "vid": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "events": {
                    "type": "array",
                    "maxItems": 20,
                    "items": {
                      "type": "object",
                      "properties": {
                        "c": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "v": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "t": {
                          "enum": [
                            "view",
                            "open",
                            "answer",
                            "complete",
                            "close",
                            "click"
                          ]
                        },
                        "p": {
                          "type": "string",
                          "maxLength": 32
                        }
                      },
                      "required": [
                        "c",
                        "v",
                        "t"
                      ],
                      "additionalProperties": false
                    }
                  }
                },
                "required": [
                  "events"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Recorded.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  },
                  "required": [
                    "ok"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The body does not fit.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/campaign-responses": {
      "post": {
        "summary": "Send survey answers",
        "tags": [
          "Campaigns"
        ],
        "description": "The answers of one pass through a survey, sent after every page. The same `attempt` again adds to the same response; `complete` is final. `answers` maps a question block id to `{ v, other? }` and is checked against the version `v` (CAMPAIGNS-DATA.md section 7). Needs no session.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {}
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "c": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "v": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "attempt": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "status": {
                    "enum": [
                      "partial",
                      "complete"
                    ]
                  },
                  "answers": {
                    "type": "object",
                    "additionalProperties": true
                  },
                  "locale": {
                    "type": "string",
                    "maxLength": 16
                  },
                  "page": {
                    "type": "string",
                    "maxLength": 500
                  },
                  "device": {
                    "enum": [
                      "desktop",
                      "tablet",
                      "mobile"
                    ]
                  },
                  "durationMs": {
                    "type": "integer",
                    "minimum": 0
                  },
                  "vid": {
                    "type": "string",
                    "format": "uuid"
                  }
                },
                "required": [
                  "c",
                  "v",
                  "attempt",
                  "status",
                  "answers",
                  "locale"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  },
                  "required": [
                    "ok"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The body does not fit (`invalid_request`), or the answers do not fit the version (`invalid_answers`).",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request",
                        "invalid_answers"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "409": {
            "description": "The campaign is paused, ended, full or not there.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "campaign_closed"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/feedback/{id}/vote": {
      "put": {
        "summary": "Agree (+1)",
        "tags": [
          "Votes"
        ],
        "description": "Sets the vote as a state, not a toggle: repeating the request changes nothing.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The feedback id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "value": {
                    "const": 1
                  }
                },
                "required": [
                  "value"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The new state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "score": {
                      "type": "integer"
                    },
                    "myVote": {
                      "oneOf": [
                        {
                          "const": 1
                        },
                        {
                          "type": "null"
                        }
                      ]
                    }
                  },
                  "required": [
                    "score",
                    "myVote"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The body is not `{ \"value\": 1 }`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No session, an expired one, or one that belongs to another company. All three look the same on purpose.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "unauthenticated"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "404": {
            "description": "No such item, or one the caller may not see.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "feedback_not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "summary": "Withdraw the vote",
        "tags": [
          "Votes"
        ],
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The feedback id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "The new state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "score": {
                      "type": "integer"
                    },
                    "myVote": {
                      "oneOf": [
                        {
                          "const": 1
                        },
                        {
                          "type": "null"
                        }
                      ]
                    }
                  },
                  "required": [
                    "score",
                    "myVote"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No session, an expired one, or one that belongs to another company. All three look the same on purpose.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "unauthenticated"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "404": {
            "description": "No such item, or one the caller may not see.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "feedback_not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/feedback/{id}/comments": {
      "get": {
        "summary": "List comments",
        "tags": [
          "Comments"
        ],
        "description": "Oldest first. No session needed.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The feedback id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "The `nextCursor` of the previous page. Opaque. A cursor that does not fit the request answers `400 invalid_request`.",
            "schema": {
              "type": "string",
              "maxLength": 2048
            }
          }
        ],
        "security": [
          {},
          {
            "bearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "One page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CommentPage"
                }
              }
            }
          },
          "400": {
            "description": "The cursor does not fit.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "404": {
            "description": "No such item, or one the caller may not see.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "feedback_not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "summary": "Add a comment",
        "tags": [
          "Comments"
        ],
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The feedback id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "description": "A client-generated key, 8 to 128 characters of `A-Z a-z 0-9 _ -`. Repeating a request with the same key and the same content returns the first result; the same key with different content answers `422 idempotency_key_reused`.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{8,128}$"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "body": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 2000,
                    "description": "In characters (code points), after trimming; control characters are refused."
                  }
                },
                "required": [
                  "body"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A replay with the same key.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "comment": {
                      "$ref": "#/components/schemas/Comment"
                    },
                    "commentCount": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "comment",
                    "commentCount"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "comment": {
                      "$ref": "#/components/schemas/Comment"
                    },
                    "commentCount": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "comment",
                    "commentCount"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The body or the key is wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request",
                        "idempotency_key_required"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No session, an expired one, or one that belongs to another company. All three look the same on purpose.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "unauthenticated"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "404": {
            "description": "No such item, or one the caller may not see.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "feedback_not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "413": {
            "description": "The body is too large.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "payload_too_large"
                      ]
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "The key was used with different content.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "idempotency_key_reused"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/comments/{commentId}/helpful": {
      "put": {
        "summary": "Mark a comment Helpful",
        "tags": [
          "Comments"
        ],
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "commentId",
            "in": "path",
            "required": true,
            "description": "The comment id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "The new state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "helpfulCount": {
                      "type": "integer"
                    },
                    "mine": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "helpfulCount",
                    "mine"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No session, an expired one, or one that belongs to another company. All three look the same on purpose.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "unauthenticated"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Your own comment, or the origin is refused.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "own_comment",
                        "origin_not_allowed",
                        "origin_missing"
                      ]
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No such comment, or one the caller may not see.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "comment_not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "summary": "Remove the Helpful mark",
        "tags": [
          "Comments"
        ],
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "commentId",
            "in": "path",
            "required": true,
            "description": "The comment id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "The new state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "helpfulCount": {
                      "type": "integer"
                    },
                    "mine": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "helpfulCount",
                    "mine"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No session, an expired one, or one that belongs to another company. All three look the same on purpose.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "unauthenticated"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Your own comment, or the origin is refused.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "own_comment",
                        "origin_not_allowed",
                        "origin_missing"
                      ]
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No such comment, or one the caller may not see.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "comment_not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/auth/code": {
      "post": {
        "summary": "Send a sign-in code",
        "tags": [
          "Sign-in"
        ],
        "description": "Emails a six-digit code. The answer is the same for a known and an unknown address.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 254
                  },
                  "locale": {
                    "type": "string",
                    "maxLength": 35,
                    "description": "A BCP 47 tag for the language of the letter; an unknown one falls back to English."
                  }
                },
                "required": [
                  "email"
                ]
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  },
                  "required": [
                    "ok"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The body did not parse.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "The letter could not be sent; try again.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "mail_not_sent"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/auth/session": {
      "post": {
        "summary": "Sign in with the code",
        "tags": [
          "Sign-in"
        ],
        "description": "The code is 6 digits; spaces and dashes are ignored. A wrong code and an expired one answer the same. Five wrong guesses burn the code.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "code": {
                    "type": "string",
                    "description": "6 digits"
                  }
                },
                "required": [
                  "email",
                  "code"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Signed in.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sessionToken": {
                      "type": "string",
                      "description": "Send as `Authorization: Bearer <token>`. Valid for 30 days; scoped to the company of the project."
                    },
                    "companyId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "displayName": {
                      "oneOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "notifyReplies": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "sessionToken",
                    "companyId",
                    "displayName",
                    "notifyReplies"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The code failed, or the body did not parse.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request",
                        "invalid_code",
                        "superseded_code"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "summary": "Sign out",
        "tags": [
          "Sign-in"
        ],
        "description": "Ends the session of the bearer token. Always answers 204.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "responses": {
          "204": {
            "description": "Signed out."
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/auth/handoff": {
      "post": {
        "summary": "Continue on the public board",
        "tags": [
          "Sign-in"
        ],
        "description": "Issues a one-time code, valid for a short time. Open `<boardUrl>/<path>#handoff=<code>` in a browser and the visitor arrives signed in on the public site.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "responses": {
          "201": {
            "description": "A one-time code.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "expiresInSeconds": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "code",
                    "expiresInSeconds"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "401": {
            "description": "No session, an expired one, or one that belongs to another company. All three look the same on purpose.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "unauthenticated"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/me": {
      "patch": {
        "summary": "Update the profile",
        "tags": [
          "Profile"
        ],
        "description": "An absent field stays as it is. `displayName: null` clears the name; a blank one is refused. Unknown fields are refused.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "displayName": {
                    "oneOf": [
                      {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 80,
                        "description": "In characters; invisible and direction-changing characters are refused."
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "notifyReplies": {
                    "type": "boolean"
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The profile after the change.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Profile"
                }
              }
            }
          },
          "400": {
            "description": "A field is wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No session, an expired one, or one that belongs to another company. All three look the same on purpose.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "unauthenticated"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/attachments/{id}": {
      "put": {
        "summary": "Start an upload",
        "tags": [
          "Attachments"
        ],
        "description": "Reserves a file slot and returns a presigned URL: `PUT` the bytes to `uploadUrl` with the returned headers, then call `.../complete`. At most 5 files of 5 MB per item. The id is chosen by the client (a uuid); repeating the request with the same id and content answers 200.\n\nThe type is checked again by content after the upload, not by the declared one. Only available when `attachmentsEnabled` is true.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "A new attachment id, chosen by the client.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 255
                  },
                  "size": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 5242880
                  },
                  "declaredType": {
                    "enum": [
                      "image/png",
                      "image/jpeg",
                      "image/gif",
                      "image/webp",
                      "application/pdf",
                      "text/plain",
                      "text/markdown",
                      "application/zip"
                    ]
                  },
                  "auto": {
                    "type": "boolean",
                    "description": "The file comes from the page's `onBeforeSend`, not from the visitor; the team sees it as auto-collected. Optional."
                  }
                },
                "required": [
                  "name",
                  "size",
                  "declaredType"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The same reservation again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "uploadUrl": {
                      "type": "string",
                      "format": "uri"
                    },
                    "headers": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "string"
                      }
                    }
                  },
                  "required": [
                    "id",
                    "uploadUrl",
                    "headers"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "201": {
            "description": "Reserved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "uploadUrl": {
                      "type": "string",
                      "format": "uri"
                    },
                    "headers": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "string"
                      }
                    }
                  },
                  "required": [
                    "id",
                    "uploadUrl",
                    "headers"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "400": {
            "description": "The request is wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "invalid_request",
                        "attachment_type_not_allowed",
                        "attachment_too_large"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No session, an expired one, or one that belongs to another company. All three look the same on purpose.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "unauthenticated"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "409": {
            "description": "Too many files, or the id is taken by other content.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "too_many_attachments",
                        "attachment_conflict"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "description": "Attachments are not available right now.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "attachments_unavailable"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      },
      "delete": {
        "summary": "Drop an unused upload",
        "tags": [
          "Attachments"
        ],
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The attachment id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "responses": {
          "204": {
            "description": "Removed."
          },
          "401": {
            "description": "No session, an expired one, or one that belongs to another company. All three look the same on purpose.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "unauthenticated"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "404": {
            "description": "No such attachment.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "attachment_not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "Already used by an item.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "attachment_in_use"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/attachments/{id}/complete": {
      "post": {
        "summary": "Finish an upload",
        "tags": [
          "Attachments"
        ],
        "description": "Checks the uploaded bytes by content, strips metadata (EXIF) and makes the file ready to attach to an item.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The attachment id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {
            "bearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "Ready.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Attachment"
                }
              }
            }
          },
          "400": {
            "description": "The file was refused.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "attachment_type_not_allowed",
                        "attachment_too_large",
                        "attachment_rejected"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "No session, an expired one, or one that belongs to another company. All three look the same on purpose.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "unauthenticated"
                      ]
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "404": {
            "description": "Nothing was uploaded under this id.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "attachment_not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "The upload has not arrived yet.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "attachment_not_ready"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "description": "Busy or unavailable; see `Retry-After`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "attachments_unavailable"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/widget/{projectId}/attachments/{id}/url": {
      "get": {
        "summary": "Get a link to a file",
        "tags": [
          "Attachments"
        ],
        "description": "A short-lived link. The caller must be allowed to see the item: the author and the team always, everyone when the item shows its files.",
        "parameters": [
          {
            "name": "projectId",
            "in": "path",
            "required": true,
            "description": "The project id, shown in the developer console (Widget & install).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The attachment id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "security": [
          {},
          {
            "bearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "A link.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "url": {
                      "type": "string",
                      "format": "uri"
                    },
                    "expiresInSeconds": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "url",
                    "expiresInSeconds"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginRefused"
          },
          "404": {
            "description": "No such file, or one the caller may not see.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "attachment_not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "description": "Attachments are not available right now.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ],
                  "properties": {
                    "error": {
                      "enum": [
                        "attachments_unavailable"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/health": {
      "get": {
        "summary": "Liveness",
        "tags": [
          "Service"
        ],
        "security": [
          {}
        ],
        "responses": {
          "200": {
            "description": "The process is up.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  },
                  "required": [
                    "ok"
                  ],
                  "additionalProperties": false
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/health/ready": {
      "get": {
        "summary": "Readiness",
        "tags": [
          "Service"
        ],
        "description": "Pings the database.",
        "security": [
          {}
        ],
        "responses": {
          "200": {
            "description": "Ready.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "const": true
                    }
                  },
                  "required": [
                    "ok"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "429": {
            "description": "Too many requests.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "const": false
                    },
                    "error": {
                      "const": "rate_limited"
                    }
                  },
                  "required": [
                    "ok",
                    "error"
                  ],
                  "additionalProperties": false
                }
              }
            }
          },
          "503": {
            "description": "The database does not answer.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "const": false
                    }
                  },
                  "required": [
                    "ok"
                  ],
                  "additionalProperties": false
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "The `sessionToken` from `POST auth/session`."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string",
            "description": "A stable error code."
          }
        },
        "required": [
          "error"
        ],
        "additionalProperties": false
      },
      "FeedItem": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "type": {
            "type": "string",
            "description": "A type id of the project: `idea`, `problem`, `question`, `praise` or a custom one."
          },
          "title": {
            "type": "string"
          },
          "body": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "status": {
            "enum": [
              "pending",
              "planned",
              "in_progress",
              "done",
              "declined"
            ]
          },
          "categoryId": {
            "oneOf": [
              {
                "type": "string",
                "format": "uuid"
              },
              {
                "type": "null"
              }
            ]
          },
          "score": {
            "type": "integer",
            "description": "How many people agree."
          },
          "commentCount": {
            "type": "integer"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "myVote": {
            "oneOf": [
              {
                "const": 1
              },
              {
                "type": "null"
              }
            ]
          },
          "attachmentCount": {
            "type": "integer",
            "description": "Ready attachments bound to the item."
          },
          "path": {
            "type": "string",
            "description": "The canonical idea segment `<slug>-<ref>`; the public page is `<boardUrl>/<path>`."
          },
          "authorDisplayName": {
            "oneOf": [
              {
                "type": "string",
                "description": "The author's display name; null shows as \"Anonymous\"."
              },
              {
                "type": "null"
              }
            ]
          },
          "rating": {
            "oneOf": [
              {
                "type": "integer",
                "minimum": 1,
                "maximum": 5,
                "description": "The author's rating of the project, given with this item."
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "id",
          "type",
          "title",
          "body",
          "status",
          "categoryId",
          "score",
          "commentCount",
          "createdAt",
          "myVote",
          "attachmentCount",
          "path",
          "authorDisplayName",
          "rating"
        ],
        "additionalProperties": false
      },
      "FeedPage": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FeedItem"
            }
          },
          "nextCursor": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "items",
          "nextCursor"
        ],
        "additionalProperties": false
      },
      "Comment": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "anchor": {
            "type": "string"
          },
          "body": {
            "type": "string"
          },
          "authorDisplayName": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "isOfficial": {
            "type": "boolean"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "dateLabel": {
            "type": "string",
            "description": "A relative age in the language of the request."
          },
          "dateFull": {
            "type": "string"
          },
          "helpfulCount": {
            "type": "integer"
          },
          "mine": {
            "type": "boolean",
            "description": "The viewer marked it Helpful."
          },
          "own": {
            "type": "boolean",
            "description": "The viewer wrote it."
          },
          "long": {
            "type": "boolean"
          }
        },
        "required": [
          "id",
          "anchor",
          "body",
          "authorDisplayName",
          "isOfficial",
          "createdAt",
          "dateLabel",
          "dateFull",
          "helpfulCount",
          "mine",
          "own",
          "long"
        ],
        "additionalProperties": false
      },
      "CommentPage": {
        "type": "object",
        "properties": {
          "official": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Comment"
            },
            "description": "Pinned official replies, up to 50, on every page."
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Comment"
            }
          },
          "nextCursor": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "shown": {
            "type": "integer"
          },
          "total": {
            "type": "integer",
            "description": "Visitor comments, without the official replies."
          }
        },
        "required": [
          "official",
          "items",
          "nextCursor",
          "shown",
          "total"
        ],
        "additionalProperties": false
      },
      "Profile": {
        "type": "object",
        "properties": {
          "displayName": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "notifyReplies": {
            "type": "boolean"
          }
        },
        "required": [
          "displayName",
          "notifyReplies"
        ],
        "additionalProperties": false
      },
      "SimilarItem": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "score": {
            "type": "number"
          }
        },
        "required": [
          "id",
          "title",
          "score"
        ],
        "additionalProperties": false
      },
      "Attachment": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "contentType": {
            "type": "string"
          },
          "size": {
            "type": "integer"
          }
        },
        "required": [
          "id",
          "name",
          "contentType",
          "size"
        ],
        "additionalProperties": false
      },
      "Bootstrap": {
        "type": "object",
        "properties": {
          "project": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "companyId": {
                "type": "string",
                "format": "uuid"
              },
              "name": {
                "type": "string"
              },
              "description": {
                "oneOf": [
                  {
                    "type": "string"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "logoUrl": {
                "oneOf": [
                  {
                    "type": "string"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "settings": {
                "type": "object",
                "properties": {
                  "live": {
                    "type": "boolean",
                    "description": "false = paused in the console; show nothing."
                  },
                  "moderationMode": {
                    "enum": [
                      "post",
                      "pre"
                    ],
                    "description": "`pre`: new items wait for review."
                  },
                  "defaultLocale": {
                    "type": "string"
                  },
                  "enabledLocales": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "appearance": {
                    "type": "object",
                    "description": "Colours, placement and labels of the web widget.",
                    "additionalProperties": true
                  },
                  "contextQueryAllowlist": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "flow": {
                    "type": "object",
                    "description": "The compose flow: enabled types, their steps and texts.",
                    "additionalProperties": true
                  }
                },
                "required": [
                  "live",
                  "moderationMode",
                  "defaultLocale",
                  "enabledLocales",
                  "appearance",
                  "contextQueryAllowlist",
                  "flow"
                ],
                "additionalProperties": false
              },
              "publicBase": {
                "type": "string",
                "format": "uri"
              },
              "boardUrl": {
                "type": "string",
                "format": "uri",
                "description": "The public board of the project."
              },
              "categories": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "id",
                    "name"
                  ],
                  "additionalProperties": false
                }
              }
            },
            "required": [
              "id",
              "companyId",
              "name",
              "description",
              "logoUrl",
              "settings",
              "publicBase",
              "boardUrl",
              "categories"
            ],
            "additionalProperties": false
          },
          "counts": {
            "type": "object",
            "description": "Visible items per type id, plus `all`. A type with no items is absent: read it as 0.",
            "properties": {
              "all": {
                "type": "integer"
              }
            },
            "required": [
              "all"
            ],
            "additionalProperties": {
              "type": "integer"
            }
          },
          "feed": {
            "$ref": "#/components/schemas/FeedPage"
          },
          "attachmentsEnabled": {
            "type": "boolean"
          }
        },
        "required": [
          "project",
          "counts",
          "feed",
          "attachmentsEnabled"
        ],
        "additionalProperties": false
      },
      "Context": {
        "type": "object",
        "description": "What the client collected about where the feedback came from. Stored with the item and shown to the project team only. Every field is optional; an invalid field is dropped, not refused. Values are not verified.",
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 8192
          },
          "referrer": {
            "type": "string",
            "maxLength": 8192
          },
          "pageTitle": {
            "type": "string",
            "maxLength": 300
          },
          "viewport": {
            "type": "object",
            "properties": {
              "w": {
                "type": "number",
                "minimum": 0
              },
              "h": {
                "type": "number",
                "minimum": 0
              }
            },
            "required": [
              "w",
              "h"
            ],
            "additionalProperties": false
          },
          "device": {
            "enum": [
              "desktop",
              "tablet",
              "mobile"
            ]
          },
          "userAgent": {
            "type": "string",
            "maxLength": 512
          },
          "locale": {
            "type": "string",
            "maxLength": 35
          },
          "timeOnPageMs": {
            "type": "number"
          },
          "scrollDepthPct": {
            "type": "number"
          },
          "widgetVersion": {
            "type": "string",
            "maxLength": 32
          },
          "clientMeta": {
            "type": "object",
            "description": "Your own data: up to 50 keys (`[A-Za-z0-9_.-]`, at most 32 characters); a value is a string of up to 200 characters, a number or a boolean. Invalid entries are dropped.",
            "additionalProperties": {
              "oneOf": [
                {
                  "type": "string",
                  "maxLength": 200
                },
                {
                  "type": "number"
                },
                {
                  "type": "boolean"
                }
              ]
            }
          }
        },
        "additionalProperties": false
      },
      "FeedbackInput": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "description": "An enabled type of the project."
          },
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "Trimmed."
          },
          "body": {
            "type": "string",
            "maxLength": 5000,
            "description": "Trimmed; empty means none."
          },
          "categoryId": {
            "type": "string",
            "format": "uuid",
            "description": "A section of the project."
          },
          "rating": {
            "type": "integer",
            "minimum": 1,
            "maximum": 5,
            "description": "A rating of the project, not of this item."
          },
          "attachmentIds": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "maxItems": 5,
            "uniqueItems": true,
            "description": "Ready attachments of the same visitor."
          },
          "attachmentsPublic": {
            "type": "boolean",
            "description": "Show the files to everyone, not only the team and the author."
          },
          "context": {
            "$ref": "#/components/schemas/Context"
          }
        },
        "required": [
          "type",
          "title"
        ]
      }
    },
    "responses": {
      "RateLimited": {
        "description": "Too many requests. Wait `Retry-After` seconds.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Error"
                }
              ],
              "properties": {
                "error": {
                  "const": "rate_limited"
                }
              }
            }
          }
        }
      },
      "OriginRefused": {
        "description": "The origin is not registered for the project (or the project does not exist; the two cannot be told apart), or no `Origin` was sent.",
        "content": {
          "application/json": {
            "schema": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/Error"
                }
              ],
              "properties": {
                "error": {
                  "enum": [
                    "origin_not_allowed",
                    "origin_missing"
                  ]
                }
              }
            }
          }
        }
      }
    }
  }
}
