openapi: 3.1.0 info: title: Formbricks Hub API description: | Feedback record storage and webhook delivery for the Formbricks ecosystem. Store feedback records and subscribe to events via webhooks (Standard Webhooks). Webhook payloads are described by WebhookDeliveryPayload; use the signing_key to verify requests. A JSON request body is a single object (a JSON null is read as an empty object) whose member names match the documented spelling exactly; they are case-sensitive. A member spelled any other way, or a member name repeated within any object (free-form members such as metadata included), is rejected with 400 naming the member in invalid_params. Data after the object is rejected with 400 too, with the cause in the problem detail. A tenant_id in a request body may not contain U+FFFD, which the decoder substitutes for invalid UTF-8 and unpaired surrogates; such a value is rejected with 400. An operation that takes query parameters rejects one it does not declare with 400, naming it in invalid_params, rather than ignoring it: an ignored filter returns more records than the caller asked for, with a 200 that looks right. A repeatable parameter is sent once per value (source_type=a&source_type=b); the indexed form (source_type[0]=a) is rejected the same way, since it can decode to a different filter (a gap in the indexes becomes an empty value). A single-valued parameter given more than once, and a query string that is not well formed (a ";" separator, a broken percent escape, reported under the name "query"), are rejected with 400 too. An empty filter value is no constraint: ?source_type= is the same request as omitting source_type (an empty tenant_id is never read as "no tenant"). Hub releases up to 0.14.1 ignored unknown parameters, accepted the indexed form, kept the first of repeated single values, and read an empty value of a repeatable string filter as a filter on "". Self-hosted Hub servers advertise their deployed base URL from PUBLIC_BASE_URL so generated SDK clients can point to the correct Hub instance. Full Documentation: https://hub.formbricks.com Quick Start: https://hub.formbricks.com/quickstart contact: name: Formbricks Team url: https://formbricks.com email: hola@formbricks.com license: name: Apache-2.0 url: https://www.apache.org/licenses/LICENSE-2.0 version: 1.3.0 servers: - url: "{scheme}://{host}{basePath}" description: Deployed Formbricks Hub instance (self-hosted or Formbricks Cloud) variables: scheme: default: http enum: - http - https description: URL scheme for the Hub instance host: default: localhost:8080 description: Public hostname and optional port for the Hub instance basePath: default: "" description: Optional reverse-proxy path prefix, for example /hub - url: https://hub-internal.formbricks.com description: Formbricks staging Hub server - url: https://app.formbricks.com description: Formbricks Cloud - url: https://staging.app.formbricks.com description: Formbricks Cloud (staging) - url: http://localhost:8080 description: Local development server tags: - name: Health description: Health check endpoints - name: Feedback Records description: Feedback record CRUD and search - name: Webhooks description: Webhook subscription management - name: Tenant Data description: Tenant-scoped data purge operations - name: Tenant Settings description: Tenant-scoped enrichment settings - name: Taxonomy description: Automatic topic/subtopic taxonomy generation, run history, tree browsing, and node edits - name: Enrichment Status description: Tenant-scoped enrichment progress (translation, sentiment, emotions) security: - ApiKeyAuth: [] paths: /health: get: tags: - Health summary: Health check description: Returns OK if the service is running operationId: health-check security: [] # No authentication required for health check responses: "200": description: Service is healthy content: text/plain: schema: type: string example: "OK" /v1/feedback-records: get: tags: - Feedback Records summary: List feedback records with filters description: Lists feedback records for a required tenant_id with optional additional filters and pagination operationId: list-feedback-records parameters: - $ref: '#/components/parameters/FeedbackRecordsTenantId' - $ref: '#/components/parameters/FeedbackRecordsSubmissionId' - $ref: '#/components/parameters/FeedbackRecordsSourceType' - $ref: '#/components/parameters/FeedbackRecordsSourceId' - $ref: '#/components/parameters/FeedbackRecordsFieldId' - $ref: '#/components/parameters/FeedbackRecordsFieldGroupId' - $ref: '#/components/parameters/FeedbackRecordsFieldType' - $ref: '#/components/parameters/FeedbackRecordsValueId' - $ref: '#/components/parameters/FeedbackRecordsUserId' - $ref: '#/components/parameters/FeedbackRecordsSourceName' - $ref: '#/components/parameters/FeedbackRecordsLanguage' - $ref: '#/components/parameters/FeedbackRecordsSince' - $ref: '#/components/parameters/FeedbackRecordsUntil' - $ref: '#/components/parameters/FeedbackRecordsCreatedSince' - $ref: '#/components/parameters/FeedbackRecordsCreatedUntil' - $ref: '#/components/parameters/FeedbackRecordsUpdatedSince' - $ref: '#/components/parameters/FeedbackRecordsUpdatedUntil' - $ref: '#/components/parameters/FeedbackRecordsValueNumberMin' - $ref: '#/components/parameters/FeedbackRecordsValueNumberMax' - $ref: '#/components/parameters/FeedbackRecordsValueDateMin' - $ref: '#/components/parameters/FeedbackRecordsValueDateMax' - $ref: '#/components/parameters/FeedbackRecordsSentiment' - $ref: '#/components/parameters/FeedbackRecordsEmotions' - $ref: '#/components/parameters/FeedbackRecordsSentimentScoreMin' - $ref: '#/components/parameters/FeedbackRecordsSentimentScoreMax' - $ref: '#/components/parameters/FeedbackRecordsHasSentiment' - $ref: '#/components/parameters/FeedbackRecordsHasEmotions' - $ref: '#/components/parameters/FeedbackRecordsHasTranslation' - name: sort in: query description: | Column to order by. Defaults to collected_at. collected_at and created_at never change. updated_at does, so it is accepted only with order=asc: ascending, a record that changes while you page normally moves ahead of the cursor and is returned again (upsert it by id); descending, every such record would land behind the cursor and be lost, so that combination is a 400. Ascending still misses a write that started before the cursor's position and commits after it; the next run picks it up when its updated_since is the previous run's start minus an overlap. Use sort=updated_at&order=asc with updated_since and updated_until for incremental extraction, both derived from the Date header of one request made at the run's start (see the filtering and sorting guide): every page is one ordered index range. schema: type: string default: collected_at enum: - collected_at - created_at - updated_at - name: order in: query description: Sort direction. Defaults to desc, so sort=updated_at needs order=asc given explicitly. Rows tied on the sort column are ordered by id ascending. schema: type: string default: desc enum: - asc - desc - name: limit in: query description: Number of results to return (max 1000) schema: type: integer description: Number of results to return (max 1000) format: int64 default: 100 minimum: 1 maximum: 1000 - name: cursor in: query description: | Omit for the first page. For the next page, use the exact value from the previous response's next_cursor. Opaque (base64-encoded); keyset pagination. A cursor is a position within one specific ordering. Presenting it with a different sort or order returns 400 — restart pagination without a cursor instead. A cursor issued before sort control existed records no ordering; it is treated as a position in the default collected_at/desc listing and is accepted only there. schema: type: string example: "eyJ0IjoiMjAyNC0wMS0xNVQxMDozMDowMFoiLCJpIjoiMDE4ZTEyMzQtNTY3OC05YWJjLWRlZjAtMTIzNDU2Nzg5YWJjIn0=" responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/ListFeedbackRecordsOutputBody' examples: basic: summary: Basic list response value: data: - id: "018e1234-5678-9abc-def0-123456789abc" collected_at: "2024-01-15T10:30:00Z" created_at: "2024-01-15T10:30:00Z" updated_at: "2024-01-15T10:30:00Z" source_type: "survey" field_id: "q1" field_type: "rating" field_label: "How satisfied are you?" value_number: 9 source_id: "survey-123" source_name: "Q1 NPS Survey" user_id: "user-abc-123" tenant_id: "org-123" submission_id: "550e8400-e29b-41d4-a716-446655440000" limit: 100 "400": description: | Bad Request. Returned for a query parameter this operation does not declare (or sends in the indexed form, or repeats when it takes one value), a malformed query string, an unknown enum value (sentiment, emotions, field_type, sort, order), an inverted range whose *_min/*_since exceeds its *_max/*_until, a malformed cursor, or a cursor issued for a different sort/order. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' examples: inverted_range: summary: A range filter supplied backwards value: type: "https://hub.formbricks.com/problems/validation" title: "Validation Error" status: 400 code: "validation" detail: "One or more request parameters are invalid" request_id: "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b" invalid_params: - name: "sentiment_score_min" reason: "must be less than or equal to sentiment_score_max" cursor_sort_mismatch: summary: A cursor carried across a change of ordering value: type: "https://hub.formbricks.com/problems/validation" title: "Validation Error" status: 400 code: "validation" detail: "One or more request parameters are invalid" request_id: "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b" invalid_params: - name: "cursor" reason: "was issued for a different sort/order; restart pagination without a cursor, or keep sort and order unchanged" unknown_parameter: summary: A misspelled filter, rejected rather than ignored value: type: "https://hub.formbricks.com/problems/validation" title: "Validation Error" status: 400 code: "validation" detail: "One or more request parameters are invalid" request_id: "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b" invalid_params: - name: "updatd_since" reason: "is not a recognized query parameter for this operation" "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' examples: validation_error: summary: Validation error value: type: "https://hub.formbricks.com/problems/validation" title: "Validation Error" status: 400 code: "validation" detail: "One or more request parameters are invalid" instance: "/v1/feedback-records" request_id: "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b" invalid_params: - name: "tenant_id" reason: "is required" post: tags: - Feedback Records summary: Create a new feedback record description: Creates a new feedback record data point operationId: create-feedback-record requestBody: content: application/json: schema: $ref: '#/components/schemas/CreateFeedbackRecordInputBody' examples: rating: summary: Rating feedback value: source_type: "survey" field_id: "q1" field_type: "rating" field_label: "How satisfied are you?" value_number: 9 source_id: "survey-123" source_name: "Q1 NPS Survey" user_id: "user-abc-123" tenant_id: "org-123" submission_id: "550e8400-e29b-41d4-a716-446655440000" language: "en" text: summary: Text feedback value: source_type: "survey" field_id: "q2" field_type: "text" field_label: "What can we improve?" value_text: "Great service! Keep it up." source_id: "survey-123" source_name: "Q1 NPS Survey" user_id: "user-abc-123" tenant_id: "org-123" submission_id: "550e8400-e29b-41d4-a716-446655440000" language: "en" nps: summary: NPS score value: source_type: "survey" field_id: "nps" field_type: "nps" field_label: "How likely are you to recommend us?" value_number: 8 source_id: "survey-123" source_name: "NPS Survey" user_id: "user-abc-123" tenant_id: "org-123" submission_id: "550e8400-e29b-41d4-a716-446655440000" language: "en" boolean: summary: Boolean feedback value: source_type: "survey" field_id: "q3" field_type: "boolean" field_label: "Would you use our service again?" value_boolean: true source_id: "survey-123" source_name: "Q1 NPS Survey" user_id: "user-abc-123" tenant_id: "org-123" submission_id: "550e8400-e29b-41d4-a716-446655440000" language: "en" ranking: summary: Ranking question item value: source_type: "survey" field_id: "feature_priority__reports" field_type: "number" field_label: "Reports" field_group_id: "feature_priority" field_group_label: "Rank these features by importance" value_number: 1 source_id: "survey-123" source_name: "Feature Priority Survey" user_id: "user-abc-123" tenant_id: "org-123" submission_id: "550e8400-e29b-41d4-a716-446655440000" metadata: question_type: "ranking" total_items: 3 matrix: summary: Matrix rating item value: source_type: "survey" field_id: "feature_satisfaction__ease_of_use" field_type: "rating" field_label: "Ease of Use" field_group_id: "feature_satisfaction" field_group_label: "Rate your satisfaction with each feature" value_number: 4 source_id: "survey-123" source_name: "Feature Satisfaction Survey" user_id: "user-abc-123" tenant_id: "org-123" submission_id: "550e8400-e29b-41d4-a716-446655440000" metadata: question_type: "matrix" required: true responses: "201": description: Created content: application/json: schema: $ref: '#/components/schemas/FeedbackRecordData' examples: created: summary: Created feedback record value: id: "018e1234-5678-9abc-def0-123456789abc" collected_at: "2024-01-15T10:30:00Z" created_at: "2024-01-15T10:30:00Z" updated_at: "2024-01-15T10:30:00Z" source_type: "survey" field_id: "q1" field_type: "rating" field_label: "How satisfied are you?" value_number: 9 source_id: "survey-123" source_name: "Q1 NPS Survey" user_id: "user-abc-123" tenant_id: "org-123" submission_id: "550e8400-e29b-41d4-a716-446655440000" language: "en" links: GetCreatedRecord: operationId: get-feedback-record parameters: id: '$response.body#/id' description: Retrieve the created feedback record by ID UpdateCreatedRecord: operationId: update-feedback-record parameters: id: '$response.body#/id' description: Update the created feedback record by ID DeleteCreatedRecord: operationId: delete-feedback-record parameters: id: '$response.body#/id' description: Delete the created feedback record by ID "400": description: Bad Request (e.g. validation error) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' examples: invalid_field_type: summary: Invalid field_type enum value: type: "https://hub.formbricks.com/problems/validation" title: "Validation Error" status: 400 code: "validation" detail: "One or more request parameters are invalid" instance: "/v1/feedback-records" request_id: "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b" invalid_params: - name: "field_type" reason: "has invalid value \"textt\"; must be one of: text, categorical, nps, csat, ces, rating, number, boolean, date" "413": description: Request body exceeds the maximum allowed size. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict. Either a duplicate tenant_id/submission_id/field_id (code `conflict`, terminal), or a tenant data purge is in progress for this tenant (code `tenant_write_conflict`, retryable – retry after the purge completes). content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' delete: tags: - Feedback Records summary: Delete feedback records by user ID description: | Permanently deletes feedback record data points for the specified user_id/data subject. Omit tenant_id to delete that user_id across all tenants for GDPR Article 17 (Right to Erasure) requests. Provide tenant_id to restrict deletion to that tenant only. Derived embeddings for deleted feedback records are removed by database cascade. The operation is idempotent; repeated calls return deleted_count 0 after matching records have already been deleted. operationId: delete-feedback-records-by-user parameters: - name: user_id in: query description: Delete records matching this user ID (required). NULL bytes not allowed. required: true schema: type: string description: Delete records matching this user ID (required). NULL bytes not allowed. minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "user-abc-123" - name: tenant_id in: query description: Optional tenant scope. Omit this parameter to delete all records matching user_id across tenants; provide it to delete only records for this tenant. Empty strings and NULL bytes are not allowed. schema: type: string description: Optional tenant scope. Omit this parameter to delete all records matching user_id across tenants; provide it to delete only records for this tenant. Empty strings and NULL bytes are not allowed. minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/DeleteFeedbackRecordsByUserOutputBody' examples: success: summary: Successful feedback records delete by user value: deleted_count: 42 message: "Successfully deleted 42 feedback records" "400": description: Bad Request (e.g. validation error on query parameters) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict (code `tenant_write_conflict`) – the erasure could not be serialized: either a tenant data purge is in progress for a tenant holding records for this user, or the user's records changed mid-deletion (drift guard). No records were deleted. The conflict is transient; retry the request. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/feedback-records/count: get: tags: - Feedback Records summary: Count feedback records description: Returns the total number of feedback records matching the given filters. Supports the same query parameters as the list endpoint. operationId: count-feedback-records parameters: - $ref: '#/components/parameters/FeedbackRecordsTenantId' - $ref: '#/components/parameters/FeedbackRecordsSubmissionId' - $ref: '#/components/parameters/FeedbackRecordsSourceType' - $ref: '#/components/parameters/FeedbackRecordsSourceId' - $ref: '#/components/parameters/FeedbackRecordsFieldId' - $ref: '#/components/parameters/FeedbackRecordsFieldGroupId' - $ref: '#/components/parameters/FeedbackRecordsFieldType' - $ref: '#/components/parameters/FeedbackRecordsValueId' - $ref: '#/components/parameters/FeedbackRecordsUserId' - $ref: '#/components/parameters/FeedbackRecordsSourceName' - $ref: '#/components/parameters/FeedbackRecordsLanguage' - $ref: '#/components/parameters/FeedbackRecordsSince' - $ref: '#/components/parameters/FeedbackRecordsUntil' - $ref: '#/components/parameters/FeedbackRecordsCreatedSince' - $ref: '#/components/parameters/FeedbackRecordsCreatedUntil' - $ref: '#/components/parameters/FeedbackRecordsUpdatedSince' - $ref: '#/components/parameters/FeedbackRecordsUpdatedUntil' - $ref: '#/components/parameters/FeedbackRecordsValueNumberMin' - $ref: '#/components/parameters/FeedbackRecordsValueNumberMax' - $ref: '#/components/parameters/FeedbackRecordsValueDateMin' - $ref: '#/components/parameters/FeedbackRecordsValueDateMax' - $ref: '#/components/parameters/FeedbackRecordsSentiment' - $ref: '#/components/parameters/FeedbackRecordsEmotions' - $ref: '#/components/parameters/FeedbackRecordsSentimentScoreMin' - $ref: '#/components/parameters/FeedbackRecordsSentimentScoreMax' - $ref: '#/components/parameters/FeedbackRecordsHasSentiment' - $ref: '#/components/parameters/FeedbackRecordsHasEmotions' - $ref: '#/components/parameters/FeedbackRecordsHasTranslation' responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/CountFeedbackRecordsOutputBody' "400": description: | Bad Request. Returned for a query parameter this operation does not declare (sort, order, limit and cursor included: they belong to the list, and cannot change a count), an unknown enum value (sentiment, emotions, field_type), an inverted range whose *_min/*_since exceeds its *_max/*_until, or a missing or blank tenant_id. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' examples: inverted_range: summary: A range filter supplied backwards value: type: "https://hub.formbricks.com/problems/validation" title: "Validation Error" status: 400 code: "validation" detail: "One or more request parameters are invalid" request_id: "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b" invalid_params: - name: "sentiment_score_min" reason: "must be less than or equal to sentiment_score_max" "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/feedback-records/{id}: get: tags: - Feedback Records summary: Get a feedback record by ID description: Retrieves a single feedback record data point by its UUID, including its read-time assignment in the active directory taxonomy. The taxonomy field is null if the lookup is temporarily unavailable. operationId: get-feedback-record parameters: - name: id in: path description: Feedback Record ID (UUID) required: true schema: type: string description: Feedback Record ID (UUID) format: uuid responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/FeedbackRecordDetailData' "400": description: Bad Request (e.g. invalid UUID) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' delete: tags: - Feedback Records summary: Delete a feedback record description: Permanently deletes a feedback record data point operationId: delete-feedback-record parameters: - name: id in: path description: Feedback Record ID (UUID) required: true schema: type: string description: Feedback Record ID (UUID) format: uuid responses: "204": description: No Content "400": description: Bad Request (e.g. invalid UUID) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict – a tenant data purge is in progress for this record's tenant (code `tenant_write_conflict`); retry after the purge completes. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' patch: tags: - Feedback Records summary: Update a feedback record description: |- Updates specific fields of a feedback record data point. Changing `value_text` clears the server-generated enrichment fields computed from it (`sentiment`, `sentiment_score`, `emotions`, `value_text_translated`, `translation_lang_key`) and queues re-enrichment; changing `language` clears and re-queues the translation pair only. The response reflects the cleared state — the fields are absent until the asynchronous re-enrichment completes. operationId: update-feedback-record parameters: - name: id in: path description: Feedback Record ID (UUID) required: true schema: type: string description: Feedback Record ID (UUID) format: uuid requestBody: content: application/json: schema: $ref: '#/components/schemas/UpdateFeedbackRecordInputBody' examples: update_text: summary: Update text value value: value_text: "Updated feedback text" update_number: summary: Update numeric value value: value_number: 10 update_metadata: summary: Update metadata value: metadata: tags: - "important" - "follow-up" priority: "high" update_user: summary: Update user ID value: user_id: "user-xyz-789" required: true responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/FeedbackRecordData' examples: updated: summary: Updated feedback record value: id: "018e1234-5678-9abc-def0-123456789abc" collected_at: "2024-01-15T10:30:00Z" created_at: "2024-01-15T10:30:00Z" updated_at: "2024-01-15T11:00:00Z" source_type: "survey" field_id: "q1" field_type: "rating" field_label: "How satisfied are you?" value_number: 10 source_id: "survey-123" source_name: "Q1 NPS Survey" user_id: "user-abc-123" tenant_id: "org-123" submission_id: "550e8400-e29b-41d4-a716-446655440000" language: "en" metadata: tags: - "important" - "follow-up" "400": description: Bad Request (e.g. invalid UUID, validation error) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "413": description: Request body exceeds the maximum allowed size. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict – a tenant data purge is in progress for this record's tenant (code `tenant_write_conflict`); retry after the purge completes. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' examples: not_found: summary: Record not found value: type: "https://hub.formbricks.com/problems/not-found" title: "Not Found" status: 404 code: "not_found" detail: "feedback record not found" instance: "/v1/feedback-records/018e1234-5678-9abc-def0-123456789abc" request_id: "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b" details: resource_type: "feedback record" validation_error: summary: Validation error value: type: "https://hub.formbricks.com/problems/validation" title: "Validation Error" status: 400 code: "validation" detail: "One or more request parameters are invalid" instance: "/v1/feedback-records/018e1234-5678-9abc-def0-123456789abc" request_id: "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b" invalid_params: - name: "value_text" reason: "must not contain NULL bytes" /v1/feedback-records/search/semantic: post: tags: - Feedback Records summary: Semantic search over feedback records description: | Embeds the search query and returns feedback record IDs with similarity scores (cosine, 0..1). **Only available when embeddings are configured** (EMBEDDING_PROVIDER and EMBEDDING_MODEL set). Supported providers: openai, google (Gemini Developer API / Google AI Studio), google-gemini (Gemini Enterprise Agent Platform API). When embeddings are disabled, this endpoint returns 503 Service Unavailable. Request body must include query and tenant_id (required for tenant isolation). operationId: semantic-search-feedback-records parameters: - name: limit in: query description: Number of results to return (default 10, max 100). Consistent with list endpoints. schema: type: integer default: 10 minimum: 1 maximum: 100 - name: cursor in: query description: | Omit for the first page. For the next page, use the exact value from the previous response's next_cursor. Opaque (base64-encoded); keyset pagination. schema: type: string example: "eyJkIjowLjEsImkiOiIwMThlMTIzNC01Njc4LTlhYmMtZGVmMC0xMTExMTExMTExMTEifQ==" - name: min_score in: query description: Minimum similarity score (0..1); only results with score >= min_score are returned. Default 0.7 to reduce noise. schema: type: number format: float minimum: 0 maximum: 1 default: 0.7 requestBody: content: application/json: schema: $ref: '#/components/schemas/SemanticSearchInputBody' example: query: "What do users think about login speed?" tenant_id: "org-123" required: true responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/SemanticSearchResponse' "400": description: Bad Request (e.g. missing tenant_id, empty query, or invalid cursor) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "413": description: Request body exceeds the maximum allowed size. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "503": description: Service Unavailable (embeddings are not configured) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/feedback-records/{id}/similar: get: tags: - Feedback Records summary: Get similar feedback records description: | Returns feedback record IDs and similarity scores for records similar to the given one (by embedding). **Only available when embeddings are configured** (EMBEDDING_PROVIDER and EMBEDDING_MODEL set). Supported providers: openai, google (Gemini Developer API / Google AI Studio), google-gemini (Gemini Enterprise Agent Platform API). When embeddings are disabled, this endpoint returns 503 Service Unavailable. Hub derives the tenant from the source feedback record and scopes the nearest-neighbor search to that tenant. operationId: similar-feedback-records parameters: - name: id in: path description: Feedback record ID (UUID) required: true schema: type: string format: uuid example: "018e1234-5678-9abc-def0-123456789abc" - name: limit in: query description: Number of results to return (default 10, max 100). Consistent with list endpoints. schema: type: integer default: 10 minimum: 1 maximum: 100 - name: cursor in: query description: | Omit for the first page. For the next page, use the exact value from the previous response's next_cursor. Opaque (base64-encoded); keyset pagination. schema: type: string example: "eyJkIjowLjEsImkiOiIwMThlMTIzNC01Njc4LTlhYmMtZGVmMC0xMTExMTExMTExMTEifQ==" - name: min_score in: query description: Minimum similarity score (0..1); only results with score >= min_score are returned. Default 0.7 to reduce noise. schema: type: number format: float minimum: 0 maximum: 1 default: 0.7 responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/SemanticSearchResponse' "400": description: Bad Request (e.g. invalid cursor) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found (feedback record has no embedding for the current model, or source record has no tenant) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "503": description: Service Unavailable (embeddings are not configured) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/webhooks: get: tags: - Webhooks summary: List webhooks description: Lists webhook endpoints with optional filters and pagination operationId: list-webhooks parameters: - name: enabled in: query description: Filter by enabled status schema: type: boolean example: true - name: tenant_id in: query description: Filter by tenant ID. NULL bytes not allowed. schema: type: string maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" - name: limit in: query description: Number of results to return (max 1000) schema: type: integer format: int64 default: 100 minimum: 1 maximum: 1000 - name: cursor in: query description: | Omit for the first page. For the next page, use the exact value from the previous response's next_cursor. Opaque (base64-encoded); keyset pagination. schema: type: string responses: "200": description: OK. Returns ListWebhooksOutputBody (items omit signing_key). content: application/json: schema: $ref: '#/components/schemas/ListWebhooksOutputBody' "400": description: Bad Request (e.g. invalid cursor) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' post: tags: - Webhooks summary: Create a webhook description: | Creates a new webhook endpoint. When events occur (e.g. feedback_record.created), the Hub POSTs a signed payload to the webhook URL. If signing_key is omitted, a key is auto-generated (Standard Webhooks format, whsec_...). tenant_id is required; webhooks only receive events from that exact tenant. See WebhookDeliveryPayload for the payload structure sent to your URL. operationId: create-webhook requestBody: content: application/json: schema: $ref: '#/components/schemas/CreateWebhookInputBody' examples: feedback_events: summary: Subscribe to feedback events value: url: "https://example.com/hub-events" tenant_id: "org-123" event_types: - "feedback_record.created" - "feedback_record.updated" - "feedback_record.deleted" enabled: true all_events: summary: Subscribe to all events value: url: "https://example.com/all-events" event_types: [] tenant_id: "org-123" required: true responses: "201": description: Created content: application/json: schema: $ref: '#/components/schemas/WebhookData' links: GetWebhook: operationId: get-webhook parameters: id: '$response.body#/id' description: Retrieve the created webhook by ID UpdateWebhook: operationId: update-webhook parameters: id: '$response.body#/id' description: Update the webhook by ID DeleteWebhook: operationId: delete-webhook parameters: id: '$response.body#/id' description: Delete the webhook by ID "400": description: Bad Request (e.g. validation error) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "413": description: Request body exceeds the maximum allowed size. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "403": description: Forbidden (e.g. webhook limit exceeded) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict – a tenant data purge is in progress for this tenant (code `tenant_write_conflict`); retry after the purge completes. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/webhooks/{id}: get: tags: - Webhooks summary: Get a webhook by ID description: Retrieves a single webhook endpoint by its UUID. signing_key is omitted for security. operationId: get-webhook parameters: - name: id in: path description: Webhook ID (UUID) required: true schema: type: string format: uuid example: "018e1234-5678-9abc-def0-123456789abc" responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/WebhookPublicData' "400": description: Bad Request (e.g. invalid UUID) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' patch: tags: - Webhooks summary: Update a webhook description: Updates specific fields of a webhook endpoint operationId: update-webhook parameters: - name: id in: path description: Webhook ID (UUID) required: true schema: type: string format: uuid example: "018e1234-5678-9abc-def0-123456789abc" requestBody: content: application/json: schema: $ref: '#/components/schemas/UpdateWebhookInputBody' required: true responses: "200": description: OK. Returns WebhookPublicData (signing_key omitted). content: application/json: schema: $ref: '#/components/schemas/WebhookPublicData' "400": description: Bad Request (e.g. invalid UUID) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "413": description: Request body exceeds the maximum allowed size. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict (code `tenant_write_conflict`) – the update could not be serialized against a tenant data purge in progress for this webhook's current (or target) tenant, or the webhook was modified concurrently. The conflict is transient; retry the request. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' delete: tags: - Webhooks summary: Delete a webhook description: Permanently deletes a webhook endpoint. It will no longer receive events. operationId: delete-webhook parameters: - name: id in: path description: Webhook ID (UUID) required: true schema: type: string format: uuid example: "018e1234-5678-9abc-def0-123456789abc" responses: "204": description: No Content "400": description: Bad Request (e.g. invalid UUID) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict (code `tenant_write_conflict`) – the delete could not be serialized against a tenant data purge in progress for this webhook's tenant, or the webhook was modified concurrently. The conflict is transient; retry the request. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/tenants/{tenant_id}/data: delete: tags: - Tenant Data summary: Delete tenant data description: | Permanently deletes Hub-owned data for the specified tenant_id. This endpoint is intended for tenant/account offboarding after the tenant has been deprovisioned and upstream writes for that tenant have stopped. This includes feedback records, derived embeddings, taxonomy data, and webhooks for the tenant. This operation is synchronous and idempotent; repeated calls return zero counts after the tenant data has already been deleted. The purge is serialized against tenant-owned writes: while it runs, Hub-owned writes for the same tenant_id are rejected with HTTP 409 (code `tenant_write_conflict`); writes for other tenants are unaffected. If tenant-owned writes are in flight when the purge starts, the purge waits up to a configured lock timeout for them to drain and then returns a retryable 409. No webhook events are published as part of this purge operation, and writes rejected during the purge publish no events. Webhook deliveries already in flight when the purge commits complete normally, and previously enqueued delivery jobs no-op after the purge (their queued payloads are removed by the job queue's retention pruning rather than by this endpoint). operationId: delete-tenant-data parameters: - name: tenant_id in: path required: true description: Tenant ID whose Hub-owned data should be deleted. Empty strings and NULL bytes are not allowed. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" responses: "200": description: Tenant data deleted content: application/json: schema: $ref: '#/components/schemas/TenantDataDeleteOutputBody' examples: success: summary: Successful tenant data delete value: tenant_id: "org-123" deleted_feedback_records: 42 deleted_embeddings: 17 deleted_webhooks: 3 deleted_taxonomy_runs: 4 deleted_taxonomy_run_input_records: 1200 deleted_taxonomy_clusters: 28 deleted_taxonomy_cluster_memberships: 1200 deleted_taxonomy_nodes: 36 deleted_taxonomy_active_runs: 2 deleted_taxonomy_node_events: 9 message: "Successfully deleted tenant data for org-123" "400": description: Bad Request (e.g. missing or invalid tenant_id) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict – tenant-owned writes were in flight and did not drain within the purge lock timeout (code `tenant_write_conflict`). No data was deleted; retry the purge. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/tenants/{tenant_id}/feedback-records: delete: tags: - Tenant Data summary: Purge all feedback records for a tenant description: | Permanently deletes every feedback record for the specified tenant_id, everything derived from those records — embeddings and the enrichment stored on each record (sentiment, emotions, translations) — and the taxonomy built on them: runs, clusters, nodes, cluster memberships, active-run pointers and node events. This is intended for emptying a dataset that stays in use, so it removes the tenant's DATA but never its CONFIGURATION: webhooks and tenant settings are left untouched. That is the difference from `DELETE /v1/tenants/{tenant_id}/data`, which additionally deletes both and is meant for offboarding a deprovisioned tenant. The taxonomy is removed rather than preserved because it describes records that no longer exist: its per-node counts are derived from memberships and would all read zero, while a run's own stored counters (`record_count`, `cluster_count`, a cluster's `size`) are historical and would keep advertising the old numbers. A new taxonomy can be generated once the dataset has enough feedback again; manual node renames and removals are per-run and do not survive a regeneration in any case. The tenant is a required path segment, so it cannot be omitted the way a filter on a collection delete could be — dropping it routes elsewhere rather than widening the operation to every tenant. Asynchronous: the request schedules the purge and returns 202 immediately, because the deletion is unbounded and can outlive a request. There is therefore no deleted count in the response. Poll `GET /v1/feedback-records/count?tenant_id=...` to observe progress. The purge removes only the records that existed when it started — it takes a high-water mark up front — so feedback ingested while it runs is never deleted. For a dataset that is no longer receiving feedback the count reaching zero means the purge is complete; for one still ingesting, a nonzero count is those newer records. Note this means the count alone cannot distinguish "finished" from "failed" on an active dataset. Idempotent and safe to repeat. Requesting a purge while one is already running for the same tenant joins the running purge rather than queueing a second one, and still returns 202. A purge requested after an earlier one finished starts a new run. Records are deleted in committed batches, so a purge interrupted by a restart or a timeout keeps the progress it made and resumes on retry; the taxonomy is removed in a final step once the records are gone. While each step runs, the tenant's write lock is held exclusively and Hub-owned writes for that tenant are rejected with HTTP 409 (code `tenant_write_conflict`); the lock is released between steps, and writes for other tenants are never affected. This does not apply to the request below — scheduling a purge takes no lock, so this endpoint does not return 409. A batch that cannot acquire the lock is retried by the job queue without any caller action, up to a bounded number of attempts. No webhook events are published for a purge, and no webhooks are deleted. Enrichment jobs already queued for purged records no-op when they run, since the record they reference is gone. operationId: purge-tenant-feedback-records parameters: - name: tenant_id in: path required: true description: Tenant ID whose feedback records should be purged. Empty strings and NULL bytes are not allowed. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" responses: "202": description: Purge accepted and scheduled content: application/json: schema: $ref: '#/components/schemas/FeedbackRecordsPurgeOutputBody' examples: accepted: summary: Purge accepted value: tenant_id: "org-123" status: "accepted" message: "Feedback records purge accepted for org-123" "400": description: Bad Request (e.g. missing or invalid tenant_id) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/tenants/{tenant_id}/settings: get: tags: - Tenant Settings summary: Get tenant settings description: | Returns the enrichment settings for the specified tenant_id. A tenant that has not configured any settings yet returns HTTP 200 with default (unset) values rather than 404; consumers decide the fallback behavior for unset values. Settings are tenant-scoped and never shared across tenants. operationId: get-tenant-settings parameters: - name: tenant_id in: path required: true description: Tenant ID whose settings should be returned. Empty strings and NULL bytes are not allowed. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" responses: "200": description: Tenant settings content: application/json: schema: $ref: '#/components/schemas/TenantSettingsOutputBody' examples: configured: summary: Tenant with a configured target language value: tenant_id: "org-123" settings: target_language: "en-US" unset: summary: Tenant with no settings yet (defaults) value: tenant_id: "org-123" settings: {} "400": description: Bad Request (e.g. missing or invalid tenant_id) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' put: tags: - Tenant Settings summary: Update tenant settings description: | Creates or replaces the enrichment settings for the specified tenant_id (full replace of the settings object). The tenant_id is taken from the path, so a request can only ever modify its own tenant's settings. target_language is normalized to a canonical BCP-47 locale (e.g. "en-us" becomes "en-US"); send an empty string to clear it. While a tenant data purge runs for the same tenant_id, this write is rejected with HTTP 409 (code `tenant_write_conflict`) and may be retried. operationId: update-tenant-settings parameters: - name: tenant_id in: path required: true description: Tenant ID whose settings should be updated. Empty strings and NULL bytes are not allowed. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdateTenantSettingsInputBody' examples: set: summary: Set the target language value: target_language: "de-DE" clear: summary: Clear the target language value: target_language: "" responses: "200": description: Updated tenant settings content: application/json: schema: $ref: '#/components/schemas/TenantSettingsOutputBody' examples: success: summary: Settings after update value: tenant_id: "org-123" settings: target_language: "de-DE" "400": description: Bad Request (e.g. invalid tenant_id or target_language) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict – a tenant data purge for the same tenant_id is in progress (code `tenant_write_conflict`). No settings were written; retry later. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "413": description: Request body exceeds the maximum allowed size. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' patch: tags: - Tenant Settings summary: Partially update tenant settings description: | Partially updates the enrichment settings for the specified tenant_id using RFC 7396 JSON Merge Patch semantics (media type application/merge-patch+json; application/json is also accepted). For each member: a value sets that setting, JSON null removes it (e.g. `{"target_language": null}` clears the target language), and an omitted member is left unchanged. The tenant_id is taken from the path, so a request can only ever modify its own tenant's settings. target_language is normalized to a canonical BCP-47 locale (e.g. "en-us" becomes "en-US"); an empty string is rejected (send null to remove it). While a tenant data purge runs for the same tenant_id, this write is rejected with HTTP 409 (code `tenant_write_conflict`) and may be retried. operationId: patch-tenant-settings parameters: - name: tenant_id in: path required: true description: Tenant ID whose settings should be updated. Empty strings and NULL bytes are not allowed. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" requestBody: required: true content: application/merge-patch+json: schema: $ref: '#/components/schemas/PatchTenantSettingsInputBody' examples: set: summary: Set the target language (other settings untouched) value: target_language: "de-DE" application/json: schema: $ref: '#/components/schemas/PatchTenantSettingsInputBody' responses: "200": description: Updated tenant settings content: application/json: schema: $ref: '#/components/schemas/TenantSettingsOutputBody' examples: success: summary: Settings after the partial update value: tenant_id: "org-123" settings: target_language: "de-DE" "400": description: Bad Request (e.g. invalid tenant_id or target_language) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict – a tenant data purge for the same tenant_id is in progress (code `tenant_write_conflict`). No settings were written; retry later. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "413": description: Request body exceeds the maximum allowed size. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/tenants/{tenant_id}/enrichments/retry: post: tags: - Enrichment Status summary: Retry permanently-failed enrichments for a tenant description: | Clears the terminal failure markers for a tenant so the reconciler will attempt those records again. The automatic sweep deliberately never revives a terminal record: a terminal failure is a property of the record's own text — a content-policy block, a refusal, an input past the model's limit — so re-running it costs a provider call and fails identically. This endpoint is the override for when that premise has changed: the text was edited, or the provider changed its policy. Because of that, it is **rate limited per (tenant, enrichment)**. Clearing is a request to spend a provider call on every record already known to fail, so an unbounded clear would be a cost-amplification loop. A refused call returns `cooling_down` with the remaining wait rather than silently doing nothing. Every requested enrichment gets its own outcome — a tenant can have one cleared, one cooling down and one switched off in the same call. Responds 202: clearing is synchronous, but the retrying is not. operationId: retry-tenant-enrichments parameters: - name: tenant_id in: path required: true schema: type: string description: The tenant whose terminal failures should be cleared. requestBody: required: false description: Omit the body, or send an empty object, to clear every enrichment. content: application/json: schema: $ref: '#/components/schemas/EnrichmentRetryInputBody' responses: '202': description: Accepted; see each enrichment's outcome. content: application/json: schema: $ref: '#/components/schemas/EnrichmentRetryOutputBody' '400': description: | Bad Request. Returned for an unknown enrichment name, a body that is not a JSON object, an unknown or repeated member, malformed JSON, or a missing tenant id. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' '401': description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' '409': description: | Conflict. Returned when the reconciler is disabled on this deployment (clearing failures would have no effect until it is re-enabled), or when a tenant data purge currently holds the tenant's write lock. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' '413': description: Request body exceeds the maximum allowed size. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/enrichment-status: get: tags: - Enrichment Status summary: Get tenant enrichment status description: | Returns a tenant's enrichment progress across the record-level enrichments (translation, sentiment, emotions). For each, `enabled` reports whether the enrichment is active for the tenant (deployment-configured and switched on / with a resolvable target language), and `eligible`/`done` are directory-level counts of feedback records that qualify and that have been enriched — the UI derives "in progress" as `eligible - done`, of which `failed` is retryable and `failed_terminal` never will be. When an enrichment is not enabled its counts are zero and `disabled_reason` names the gate that closed it, so the UI can explain *why* rather than silently hiding the enrichment. `as_of` is when the counts were computed, which lets a polling client derive throughput and an ETA without the Hub computing either. The response contains counts only (no record identifiers or content). operationId: get-enrichment-status parameters: - name: tenant_id in: query required: true description: Tenant whose enrichment status should be returned. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" responses: "200": description: Tenant enrichment status content: application/json: schema: $ref: '#/components/schemas/EnrichmentStatusOutputBody' "400": description: Bad Request (e.g. missing or invalid tenant_id) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "503": description: | Service Unavailable – the enrichment status service is not available (code `service_unavailable`). content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/feedback-records/{id}/taxonomy: get: tags: - Taxonomy summary: Get a feedback record's active taxonomy assignment description: | Resolves a tenant-owned feedback record against the active directory taxonomy. The ordered path contains current visible non-root nodes, not labels copied onto the feedback record. A removed node or ancestor makes the record unclassified in the current visible tree. Records outside the active run are also unclassified; a missing active run has its own status. operationId: get-feedback-record-taxonomy parameters: - name: id in: path required: true description: Feedback record ID. schema: type: string format: uuid - name: tenant_id in: query required: true description: Tenant that owns the feedback record. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' responses: "200": description: The record's assignment in the active directory taxonomy. content: application/json: schema: $ref: '#/components/schemas/FeedbackRecordTaxonomyOutputBody' examples: classified: value: status: classified run_id: "019f177f-9aa3-705e-8195-cea2aa187268" path: - id: "019f177f-9abe-78cd-8008-f40b58e3147d" label: "Authentication" level: 1 node_type: branch - id: "019f177f-9abe-78cd-8008-f40b58e3147e" label: "Login problems" level: 2 node_type: branch - id: "019f177f-9abe-78cd-8008-f40b58e3147f" label: "Password reset" level: 3 node_type: branch - id: "019f177f-9abe-78cd-8008-f40b58e31480" label: "Reset link expired" level: 4 node_type: leaf "400": description: Bad Request (invalid record ID or missing/invalid tenant_id) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found – the record does not exist for this tenant. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/taxonomy/fields: get: tags: - Taxonomy summary: List taxonomy-capable fields description: | Returns the feedback fields that can be used to generate a taxonomy for a tenant, along with the number of text records and embedded records available per field scope (source_type, source_id, field_id). A field with no attributed source is exposed under the canonical "no source" bucket (empty source_id). Requires Hub embeddings to be configured; otherwise the endpoint returns 503. operationId: list-taxonomy-fields parameters: - name: tenant_id in: query required: true description: Tenant whose taxonomy-capable fields should be listed. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" responses: "200": description: Taxonomy-capable fields content: application/json: schema: $ref: '#/components/schemas/TaxonomyFieldsOutputBody' "400": description: Bad Request (e.g. missing or invalid tenant_id) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "503": description: | Service Unavailable – Hub embeddings are not configured, so taxonomy is unavailable (code `service_unavailable`). Retry once embeddings are enabled. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/taxonomy/runs: get: tags: - Taxonomy summary: List taxonomy runs description: | Returns taxonomy run history for a tenant, most recent first. Optionally filter by scope_type, source_type, field_id, and source_id. source_id is a tri-state filter: omit it for no source filter, pass an empty string to scope to the canonical "no source" bucket, or pass a concrete value to match that source. operationId: list-taxonomy-runs parameters: - name: tenant_id in: query required: true description: Tenant whose runs should be listed. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" - name: scope_type in: query required: false description: Optional scope type filter. Omitted means no scope type filter. schema: $ref: '#/components/schemas/TaxonomyScopeType' - name: source_type in: query required: false description: Optional source_type filter. schema: type: string maxLength: 255 pattern: '^[^\x00]*$' - name: source_id in: query required: false description: | Optional source_id filter. Omit for no filter; empty string scopes to the "no source" bucket; a concrete value matches that source. schema: type: string maxLength: 255 pattern: '^[^\x00]*$' - name: field_id in: query required: false description: Optional field_id filter. schema: type: string maxLength: 255 pattern: '^[^\x00]*$' - name: limit in: query required: false description: Maximum number of runs to return. schema: type: integer format: int64 minimum: 1 maximum: 100 responses: "200": description: Taxonomy run history content: application/json: schema: $ref: '#/components/schemas/ListTaxonomyRunsOutputBody' "400": description: Bad Request (e.g. missing/invalid tenant_id or query parameters) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' post: tags: - Taxonomy summary: Start a taxonomy run description: | Starts a manual taxonomy generation run for a field or directory scope. Hub validates that the scope has enough embedded text feedback (below the configured minimum returns 400 with an "insufficient data" validation error), creates the run, and hands it to the taxonomy compute service. Omit scope_type for the existing field scope behavior; use scope_type=directory with tenant_id only to generate one taxonomy over all text feedback in the directory. Idempotent per scope: if a run is already pending or running for the same scope, the existing run is returned with `in_progress: true` (HTTP 200) instead of starting a new one; a newly created run returns HTTP 202 with `in_progress: false`. While a tenant data purge runs for the same tenant_id, the request is rejected with HTTP 409 (code `tenant_write_conflict`) and may be retried. Requires Hub embeddings and the taxonomy service to be configured; otherwise returns 503. operationId: create-taxonomy-run requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateTaxonomyRunInputBody' examples: start: summary: Start a run for a survey field value: tenant_id: "org-123" scope_type: "field" source_type: "formbricks" source_id: "survey-abc" field_id: "feedback" actor_id: "user-42" directory: summary: Start a run for all text feedback in a directory value: tenant_id: "org-123" scope_type: "directory" actor_id: "user-42" responses: "202": description: Run accepted and started content: application/json: schema: $ref: '#/components/schemas/CreateTaxonomyRunOutputBody' "200": description: A run for this scope is already in progress; the existing run is returned. content: application/json: schema: $ref: '#/components/schemas/CreateTaxonomyRunOutputBody' "400": description: | Bad Request – invalid scope, or insufficient input (no text feedback for the scope, or fewer embedded records than the configured minimum). content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "413": description: Request body exceeds the maximum allowed size. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict – a tenant data purge for the same tenant_id is in progress (code `tenant_write_conflict`). No run was created; retry later. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "503": description: | Service Unavailable – Hub embeddings or the taxonomy compute service are not configured or did not accept the run (code `service_unavailable`). Retry later. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/taxonomy/runs/active/tree: get: tags: - Taxonomy summary: Get the active taxonomy tree for a scope description: | Returns the currently active taxonomy run and its tree for a field or directory scope. Exactly one run is active per scope at a time. Returns 404 when no run has been activated for the scope. operationId: get-active-taxonomy-tree parameters: - name: tenant_id in: query required: true description: Tenant that owns the scope. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" - name: scope_type in: query required: false description: Scope type. Omit for field scope; use directory with tenant_id only for directory taxonomy. schema: $ref: '#/components/schemas/TaxonomyScopeType' - name: source_type in: query required: false description: Source type of a field scope. Must be omitted for directory scope. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' - name: source_id in: query required: false description: Source ID of the scope; empty string is the canonical "no source" bucket. schema: type: string maxLength: 255 pattern: '^[^\x00]*$' - name: field_id in: query required: false description: Field ID of a field scope. Must be omitted for directory scope. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' responses: "200": description: The active run and its taxonomy tree content: application/json: schema: $ref: '#/components/schemas/TaxonomyTreeOutputBody' "400": description: Bad Request (e.g. missing/invalid scope parameters) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found – no active taxonomy run exists for the scope. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/taxonomy/runs/{run_id}: get: tags: - Taxonomy summary: Get a taxonomy run description: Returns a single taxonomy run by ID, scoped to the tenant. Returns 404 if the run does not belong to the tenant. operationId: get-taxonomy-run parameters: - name: run_id in: path required: true description: Taxonomy run ID. schema: type: string format: uuid example: "019f177f-9aa3-705e-8195-cea2aa187268" - name: tenant_id in: query required: true description: Tenant that owns the run. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" responses: "200": description: The taxonomy run content: application/json: schema: $ref: '#/components/schemas/TaxonomyRunData' "400": description: Bad Request (e.g. invalid run_id or missing/invalid tenant_id) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found – no run with this ID for the tenant. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/taxonomy/runs/{run_id}/tree: get: tags: - Taxonomy summary: Get a taxonomy run's tree description: Returns the run and its taxonomy tree (visible nodes only; soft-removed nodes are excluded). Tenant-scoped; returns 404 if the run does not belong to the tenant. operationId: get-taxonomy-run-tree parameters: - name: run_id in: path required: true description: Taxonomy run ID. schema: type: string format: uuid example: "019f177f-9aa3-705e-8195-cea2aa187268" - name: tenant_id in: query required: true description: Tenant that owns the run. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" responses: "200": description: The run and its taxonomy tree content: application/json: schema: $ref: '#/components/schemas/TaxonomyTreeOutputBody' "400": description: Bad Request (e.g. invalid run_id or missing/invalid tenant_id) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found – no run with this ID for the tenant. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/taxonomy/runs/{run_id}/record-counts: get: tags: - Taxonomy summary: Count feedback records per taxonomy node description: Returns the feedback-record count for every visible node in a taxonomy run. Each count is a subtree total, so a topic (branch) reports the sum of its subtopics and the root reports the run total. Tenant-scoped; returns 404 if the run does not belong to the tenant. operationId: count-taxonomy-node-records parameters: - name: run_id in: path required: true description: Taxonomy run ID. schema: type: string format: uuid example: "019f177f-9aa3-705e-8195-cea2aa187268" - name: tenant_id in: query required: true description: Tenant that owns the run. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" responses: "200": description: Per-node feedback-record counts for the run content: application/json: schema: $ref: '#/components/schemas/TaxonomyRecordCountsOutputBody' "400": description: Bad Request (e.g. invalid run_id or missing/invalid tenant_id) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found – no run with this ID for the tenant. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/taxonomy/nodes/{node_id}: patch: tags: - Taxonomy summary: Rename a taxonomy node description: | Renames a taxonomy node's label and records a rename event attributed to actor_id. Tenant-scoped; returns 404 if the node does not belong to the tenant. While a tenant data purge runs for the same tenant_id, the request is rejected with HTTP 409 (code `tenant_write_conflict`) and may be retried. operationId: rename-taxonomy-node parameters: - name: node_id in: path required: true description: Taxonomy node ID. schema: type: string format: uuid example: "019f177f-9abe-78cd-8008-f40b58e3147d" requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RenameTaxonomyNodeInputBody' examples: rename: summary: Rename a node value: tenant_id: "org-123" actor_id: "user-42" label: "Authentication Problems" responses: "200": description: The updated node content: application/json: schema: $ref: '#/components/schemas/TaxonomyNodeData' "400": description: Bad Request (e.g. invalid node_id, missing fields, or empty label) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "413": description: Request body exceeds the maximum allowed size. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found – no node with this ID for the tenant. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict – a tenant data purge for the same tenant_id is in progress (code `tenant_write_conflict`). The node was not changed; retry later. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' delete: tags: - Taxonomy summary: Soft-remove a taxonomy node description: | Soft-removes a taxonomy node (sets removed_at/removed_by) and records a soft_remove event attributed to actor_id. The node is retained for audit but excluded from tree responses. Tenant-scoped; returns 404 if the node does not belong to the tenant. While a tenant data purge runs for the same tenant_id, the request is rejected with HTTP 409 (code `tenant_write_conflict`). operationId: remove-taxonomy-node parameters: - name: node_id in: path required: true description: Taxonomy node ID. schema: type: string format: uuid example: "019f177f-9abe-78cd-8008-f40b58e3147d" - name: tenant_id in: query required: true description: Tenant that owns the node. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" - name: actor_id in: query required: true description: Identifier of the actor performing the removal (recorded in the audit event). schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' responses: "200": description: The soft-removed node content: application/json: schema: $ref: '#/components/schemas/TaxonomyNodeData' "400": description: Bad Request (e.g. invalid node_id or missing/invalid query parameters) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found – no node with this ID for the tenant. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "409": description: | Conflict – a tenant data purge for the same tenant_id is in progress (code `tenant_write_conflict`). The node was not changed; retry later. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' /v1/taxonomy/nodes/{node_id}/records: get: tags: - Taxonomy summary: List feedback records for a taxonomy node description: | Returns the feedback records assigned to a node and all of its (visible) descendant nodes, via the clusters those nodes reference. Tenant-scoped; returns 404 if the node does not belong to the tenant or has been removed. An empty `data` therefore means the node genuinely holds no records. The `limit` in the response reflects the applied cap. operationId: list-taxonomy-node-records parameters: - name: node_id in: path required: true description: Taxonomy node ID. schema: type: string format: uuid example: "019f177f-9abe-78cd-8008-f40b58e3147d" - name: tenant_id in: query required: true description: Tenant that owns the node. schema: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" - name: limit in: query required: false description: Maximum number of feedback records to return. schema: type: integer format: int64 minimum: 1 maximum: 100 responses: "200": description: Feedback records assigned to the node subtree content: application/json: schema: $ref: '#/components/schemas/TaxonomyNodeRecordsOutputBody' "400": description: Bad Request (e.g. invalid node_id or missing/invalid tenant_id) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "401": description: Unauthorized (missing or invalid API key) content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' "404": description: Not Found – no node with this ID for the tenant. content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' default: description: Error content: application/problem+json: schema: $ref: '#/components/schemas/ErrorModel' components: securitySchemes: ApiKeyAuth: type: http scheme: bearer bearerFormat: API Key description: API key authentication via Bearer token in Authorization header parameters: FeedbackRecordsTenantId: name: tenant_id in: query required: true description: Tenant ID (required for isolation). NULL bytes not allowed. schema: type: string description: Tenant ID (required for isolation). NULL bytes not allowed. minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" FeedbackRecordsSubmissionId: name: submission_id in: query description: | Filter by submission ID to group records belonging to one logical submission. Repeat the parameter to match any of several submissions; the values are OR-ed, and a single occurrence behaves exactly as it always has. Comma-separated values are NOT split. NULL bytes not allowed. style: form explode: true schema: type: array maxItems: 100 items: type: string maxLength: 255 pattern: '^[^\x00]*$' example: ["550e8400-e29b-41d4-a716-446655440000"] FeedbackRecordsSourceType: name: source_type in: query description: | Filter by source type. Repeat the parameter to match any of several source types; the values are OR-ed. NULL bytes not allowed. style: form explode: true schema: type: array maxItems: 100 items: type: string maxLength: 255 pattern: '^[^\x00]*$' example: ["survey", "review"] FeedbackRecordsSourceId: name: source_id in: query description: | Filter by source ID. Repeat the parameter to match any of several sources; the values are OR-ed. NULL bytes not allowed. style: form explode: true schema: type: array maxItems: 100 items: type: string maxLength: 255 pattern: '^[^\x00]*$' example: ["survey-123"] FeedbackRecordsSourceName: name: source_name in: query description: | Filter by source display name — the human-readable label stored alongside source_id. Prefer source_id where the records carry one: a name can be edited or translated, while the id is stable. Repeat the parameter to match any of several names; the values are OR-ed. NULL bytes not allowed. style: form explode: true schema: type: array maxItems: 100 items: type: string maxLength: 255 pattern: '^[^\x00]*$' example: ["After match survey"] FeedbackRecordsFieldId: name: field_id in: query description: | Filter by field ID — every answer to one question. Repeat the parameter to match any of several fields; the values are OR-ed. NULL bytes not allowed. style: form explode: true schema: type: array maxItems: 100 items: type: string maxLength: 255 pattern: '^[^\x00]*$' example: ["q1"] FeedbackRecordsFieldGroupId: name: field_group_id in: query description: | Filter by field group ID (for ranking/matrix questions). Repeat the parameter to match any of several groups; the values are OR-ed. NULL bytes not allowed. style: form explode: true schema: type: array maxItems: 100 items: type: string maxLength: 255 pattern: '^[^\x00]*$' example: ["feature_priority"] FeedbackRecordsFieldType: name: field_type in: query description: | Filter by field type. Repeat the parameter to match any of several types; the values are OR-ed. An empty value is ignored rather than rejected, so `?field_type=` is the same as omitting the filter. style: form explode: true schema: type: array maxItems: 9 items: $ref: '#/components/schemas/FieldType' example: ["text", "rating"] FeedbackRecordsValueId: name: value_id in: query description: | Filter by the source system's stable option id (e.g. all records for one survey choice). Repeat the parameter to match any of several options; the values are OR-ed. NULL bytes not allowed. style: form explode: true schema: type: array maxItems: 100 items: type: string maxLength: 255 pattern: '^[^\x00]*$' example: ["opt_very_satisfied"] FeedbackRecordsUserId: name: user_id in: query description: | Filter by end-user identifier — everything one person submitted. Repeat the parameter to match any of several users; the values are OR-ed. NULL bytes not allowed. style: form explode: true schema: type: array maxItems: 100 items: type: string maxLength: 255 pattern: '^[^\x00]*$' example: ["user-abc-123"] FeedbackRecordsLanguage: name: language in: query description: | Filter by the language the feedback was given in. Repeat the parameter to match any of several languages; the values are OR-ed. NULL bytes not allowed. style: form explode: true schema: type: array maxItems: 100 items: type: string maxLength: 10 pattern: '^[^\x00]*$' example: ["en", "ar"] FeedbackRecordsCreatedSince: name: created_since in: query description: | Filter by created_at >= created_since (ISO 8601, inclusive). created_at is when Hub stored the record; collected_at (see `since`) is when the feedback was given. They diverge on a historical re-import, so this is the parameter for "what did this import bring in". Must be between 1970-01-01 and 2080-12-31. schema: type: string format: date-time example: "2026-01-01T00:00:00Z" FeedbackRecordsCreatedUntil: name: created_until in: query description: | Filter by created_at <= created_until (ISO 8601, inclusive). See created_since for how created_at differs from collected_at. Must be between 1970-01-01 and 2080-12-31. schema: type: string format: date-time example: "2026-12-31T00:00:00Z" FeedbackRecordsUpdatedSince: name: updated_since in: query description: | Filter by updated_at >= updated_since (ISO 8601, inclusive). updated_at is stamped on every write to a record — create, update, and every enrichment result (translation, sentiment, emotions) — so this selects records created OR changed since a point: the parameter for incremental extraction. Pair it with sort=updated_at&order=asc. For the next run, use the time the previous run started (its first response's Date header) minus an overlap, not the largest updated_at received. The overlap must outlast the longest write to a record; Hub does not bound that, so a few minutes is a default rather than a guarantee. Permanently deleted records are never returned: periodically list every record, upsert what it returns (which also repairs any change a slow write slipped past the overlap), and remove only local records that were not returned and were created before that listing started (its first response's Date header) minus the overlap. See "Incremental extraction" in the filtering and sorting guide. schema: type: string format: date-time example: "2026-01-01T00:00:00Z" FeedbackRecordsUpdatedUntil: name: updated_until in: query description: | Filter by updated_at <= updated_until (ISO 8601, inclusive). See updated_since for what updates updated_at. schema: type: string format: date-time example: "2026-12-31T00:00:00Z" FeedbackRecordsValueNumberMin: name: value_number_min in: query description: | Filter by value_number >= value_number_min (inclusive) — e.g. NPS promoters with value_number_min=9. Records whose value_number is NULL (every non-numeric answer) are excluded, so this is never a no-op filter. schema: type: number format: double example: 9 FeedbackRecordsValueNumberMax: name: value_number_max in: query description: | Filter by value_number <= value_number_max (inclusive). Paired with value_number_min it selects a band: 9..10 is the NPS promoters, 0..6 the detractors. Records whose value_number is NULL are excluded. Supplying a max below the min is a 400, not an empty result. schema: type: number format: double example: 10 FeedbackRecordsValueDateMin: name: value_date_min in: query description: | Filter by value_date >= value_date_min (ISO 8601, inclusive) — bounds the answer to a date question, not when the feedback was collected. Records whose value_date is NULL are excluded. schema: type: string format: date-time example: "2026-01-01T00:00:00Z" FeedbackRecordsValueDateMax: name: value_date_max in: query description: | Filter by value_date <= value_date_max (ISO 8601, inclusive). Records whose value_date is NULL are excluded. schema: type: string format: date-time example: "2026-12-31T00:00:00Z" FeedbackRecordsSentiment: name: sentiment in: query description: | Filter by sentiment label. Repeat the parameter to match any of several labels (`?sentiment=negative&sentiment=very_negative`); the values are OR-ed. An empty value is ignored rather than rejected, so `?sentiment=` is the same as omitting the filter. Records that have not been enriched carry no sentiment and are therefore never matched — use has_sentiment=false to find them. style: form explode: true schema: type: array maxItems: 6 items: $ref: '#/components/schemas/SentimentValue' example: ["negative", "very_negative"] FeedbackRecordsEmotions: name: emotions in: query description: | Filter by emotion label. Repeat the parameter to match ANY of the listed emotions: a record tagged {joy} and a record tagged {joy, anger} both match `?emotions=joy&emotions=anger`. There is no "must carry all of them" form. An empty value is ignored rather than rejected, so `?emotions=` is the same as omitting the filter. style: form explode: true schema: type: array maxItems: 6 items: $ref: '#/components/schemas/EmotionValue' example: ["anger", "fear"] FeedbackRecordsSentimentScoreMin: name: sentiment_score_min in: query description: | Filter by sentiment_score >= sentiment_score_min (inclusive). The score is continuous where the label is bucketed, so this is the parameter for "the most negative feedback". Records that have not been enriched are excluded. schema: type: number format: double minimum: -1.0 maximum: 1.0 example: -0.5 FeedbackRecordsSentimentScoreMax: name: sentiment_score_max in: query description: | Filter by sentiment_score <= sentiment_score_max (inclusive). Records that have not been enriched are excluded. schema: type: number format: double minimum: -1.0 maximum: 1.0 example: 0.5 FeedbackRecordsHasSentiment: name: has_sentiment in: query description: | Filter on whether sentiment enrichment has produced a label: true selects records where sentiment IS NOT NULL, false selects those where it IS NULL. Omit for no constraint. schema: type: boolean example: true FeedbackRecordsHasEmotions: name: has_emotions in: query description: | Filter on whether the record carries emotion labels: true selects records where emotions IS NOT NULL, false selects those where it IS NULL. Note that false covers both "not yet classified" and "classified, no emotion detected" — the two are indistinguishable here. Omit for no constraint. schema: type: boolean example: true FeedbackRecordsHasTranslation: name: has_translation in: query description: | Filter on whether the record has been translated: true selects records where translation_lang_key IS NOT NULL, false selects those where it IS NULL. Omit for no constraint. schema: type: boolean example: false FeedbackRecordsSince: name: since in: query description: Filter by collected_at >= since (ISO 8601 format). Must be between 1970-01-01 and 2080-12-31. schema: type: string format: date-time description: Filter by collected_at >= since (ISO 8601 format). Must be between 1970-01-01 and 2080-12-31. example: "2024-01-01T00:00:00Z" FeedbackRecordsUntil: name: until in: query description: Filter by collected_at <= until (ISO 8601 format). Must be between 1970-01-01 and 2080-12-31. schema: type: string format: date-time description: Filter by collected_at <= until (ISO 8601 format). Must be between 1970-01-01 and 2080-12-31. example: "2024-12-31T23:59:59Z" schemas: DeleteFeedbackRecordsByUserOutputBody: type: object additionalProperties: false properties: deleted_count: type: integer description: Number of records deleted format: int64 message: type: string description: Human-readable status message required: - deleted_count - message CountFeedbackRecordsOutputBody: type: object additionalProperties: false properties: count: type: integer description: Number of feedback records matching the filters format: int64 example: 42 required: - count FeedbackRecordsPurgeOutputBody: type: object additionalProperties: false required: - tenant_id - status properties: tenant_id: type: string description: Tenant ID whose feedback records are being purged minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" status: type: string description: | Always `accepted`. The purge runs in the background, so this reports that the work was scheduled, not that it finished — poll `GET /v1/feedback-records/count` for the tenant to observe completion. enum: - accepted example: "accepted" message: type: string description: Human-readable confirmation example: "Feedback records purge accepted for org-123" TenantDataDeleteOutputBody: type: object additionalProperties: false properties: tenant_id: type: string description: Tenant ID whose data was deleted minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" deleted_feedback_records: type: integer description: Number of feedback records deleted format: int64 deleted_embeddings: type: integer description: Number of embedding rows deleted format: int64 deleted_webhooks: type: integer description: Number of webhooks deleted format: int64 deleted_taxonomy_runs: type: integer description: Number of taxonomy runs deleted format: int64 deleted_taxonomy_run_input_records: type: integer description: Number of immutable taxonomy run-input snapshot rows deleted format: int64 deleted_taxonomy_clusters: type: integer description: Number of taxonomy clusters deleted format: int64 deleted_taxonomy_cluster_memberships: type: integer description: Number of taxonomy cluster memberships deleted format: int64 deleted_taxonomy_nodes: type: integer description: Number of taxonomy nodes deleted format: int64 deleted_taxonomy_active_runs: type: integer description: Number of taxonomy active-run rows deleted format: int64 deleted_taxonomy_node_events: type: integer description: Number of taxonomy node events deleted format: int64 message: type: string description: Human-readable status message required: - tenant_id - deleted_feedback_records - deleted_embeddings - deleted_webhooks - deleted_taxonomy_runs - deleted_taxonomy_run_input_records - deleted_taxonomy_clusters - deleted_taxonomy_cluster_memberships - deleted_taxonomy_nodes - deleted_taxonomy_active_runs - deleted_taxonomy_node_events - message EnrichmentSettings: type: object additionalProperties: false description: Tenant-scoped enrichment configuration. Fields are optional; absent fields use server defaults. properties: target_language: type: string description: | Normalized BCP-47 locale (e.g. "en-US") that language enrichment translates feedback records and topic labels into. Absent or empty means not configured. maxLength: 35 example: "en-US" sentiment_enabled: type: boolean description: | Per-directory switch for sentiment enrichment. Absent means the default (enabled), so a tenant is enriched unless it has explicitly set this to false. The deployment-level sentiment provider/model gate applies on top of this. example: true emotions_enabled: type: boolean description: | Per-directory switch for emotion enrichment. Absent means the default (enabled), so a tenant is enriched unless it has explicitly set this to false. The deployment-level emotions provider/model gate applies on top of this. example: true TenantSettingsOutputBody: type: object additionalProperties: false properties: tenant_id: type: string description: Tenant ID the settings belong to minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "org-123" settings: $ref: '#/components/schemas/EnrichmentSettings' required: - tenant_id - settings UpdateTenantSettingsInputBody: type: object additionalProperties: false description: Full replacement of a tenant's enrichment settings. properties: target_language: type: string description: | Target BCP-47 locale to translate into; normalized to a canonical form (e.g. "en-us" becomes "en-US"). Send an empty string to clear it. maxLength: 35 example: "de-DE" sentiment_enabled: type: boolean description: | Enable or disable sentiment enrichment for this tenant. As a full replace, omitting it clears the setting back to the default (enabled). example: false emotions_enabled: type: boolean description: | Enable or disable emotion enrichment for this tenant. As a full replace, omitting it clears the setting back to the default (enabled). example: false PatchTenantSettingsInputBody: type: object additionalProperties: false description: | RFC 7396 JSON Merge Patch for a tenant's enrichment settings. For each member: a value sets the setting, JSON null removes it, and an omitted member is left unchanged. properties: target_language: type: [string, "null"] description: | Target BCP-47 locale to translate into; normalized to a canonical form (e.g. "en-us" becomes "en-US"). Send null to remove it; omit to leave it unchanged. An empty string is rejected. maxLength: 35 example: "de-DE" sentiment_enabled: type: [boolean, "null"] description: | Enable or disable sentiment enrichment. Send null to restore the default (enabled); omit to leave it unchanged. example: false emotions_enabled: type: [boolean, "null"] description: | Enable or disable emotion enrichment. Send null to restore the default (enabled); omit to leave it unchanged. example: false SemanticSearchInputBody: type: object additionalProperties: false properties: query: type: string minLength: 1 maxLength: 4096 description: Search query text (embedded and compared via cosine similarity) example: "What do users think about login speed?" tenant_id: type: string minLength: 1 maxLength: 255 description: Tenant ID (required for isolation; must match feedback record tenant_id). U+FFFD is not allowed (it stands in for invalid UTF-8 or an unpaired surrogate). default: "org-123" example: "org-123" pattern: '^[^\uFFFD]*$' required: - query - tenant_id SemanticSearchResponse: type: object additionalProperties: false properties: data: type: array maxItems: 100 description: List of feedback record IDs with similarity scores (0 = unrelated, 1 = identical). Consistent with list endpoints. items: $ref: '#/components/schemas/SemanticSearchResultItem' limit: type: integer description: Limit used in query (echoed for consistency with list endpoints) next_cursor: type: string description: Opaque cursor for the next page (keyset paging). Present only when there may be more results (full page returned). Omit when no next page. Use this exact value as the cursor query param for the next page. example: "eyJkIjowLjEsImkiOiIwMThlMTIzNC01Njc4LTlhYmMtZGVmMC0xMTExMTExMTExMTEifQ==" required: - data - limit SemanticSearchResultItem: type: object additionalProperties: false properties: feedback_record_id: type: string format: uuid description: Feedback record UUID score: type: number format: double description: Similarity score (0..1) field_label: type: string description: Label of the feedback field (included in embedding for context). May be empty if the source record had no label. value_text: type: string description: value_text of the feedback record. May be empty if the source had no text; embeddings are only created for records with non-empty value_text, but the field can be cleared after embedding creation. A record longer than `EMBEDDING_MAX_INPUT_BYTES` was embedded from at most its first N bytes, so the vector may not cover the whole value. required: - feedback_record_id - score - field_label - value_text CreateFeedbackRecordInputBody: type: object additionalProperties: false properties: collected_at: type: string description: When the feedback was collected (defaults to now). Must be between 1970-01-01 and 2080-12-31. format: date-time field_id: type: string description: Identifier for the question/field. NULL bytes not allowed. examples: - q1 minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' field_label: type: [string, "null"] description: The actual question text examples: - How satisfied are you? maxLength: 2048 pattern: '^[^\x00]*$' field_group_id: type: string description: Stable identifier grouping related fields (for ranking, matrix, grid questions). NULL bytes not allowed. examples: - feature_priority maxLength: 255 pattern: '^[^\x00]*$' field_group_label: type: [string, "null"] description: Human-readable question text for the group examples: - Rank these features by importance maxLength: 2048 pattern: '^[^\x00]*$' field_type: type: string description: 'Field type: text (enrichable), categorical, nps, csat, ces, rating, number, boolean, date' examples: - rating enum: - text - categorical - nps - csat - ces - rating - number - boolean - date minLength: 1 maxLength: 255 language: type: string description: ISO language code. NULL bytes not allowed. examples: - en maxLength: 10 pattern: '^[^\x00]*$' metadata: type: [object, "null"] description: >- Arbitrary context for this record, stored as JSON and returned with equivalent values (key order and number formatting are normalized): the dimensions you want to slice a dashboard by, such as channel, device, browser, OS, country, referrer, campaign, plan or tags. Values may be strings, numbers, booleans, null, or nested objects and arrays. Use snake_case keys and keep them stable across records, since a key is what a dashboard groups by: `customer_tier`, not a mix of `customerTier` and `tier`. Numbers keep full precision in storage (unlike `value_number`, which is a float64), so a large integer id round-trips exactly through this API — though a JavaScript client will still lose precision above 2^53 when it parses the response. Nothing here is redacted. Metadata is **not** sent to the LLM or embedding enrichment providers, unlike `value_text` — but it **is** included in the `feedback_record.created` and `feedback_record.updated` webhook payloads, so it reaches whatever URL a tenant has configured. Erasure covers metadata, with one precondition worth knowing: `DELETE /v1/feedback-records` matches on `user_id` and hard-deletes the whole row, metadata included — so a record written **without** a `user_id` cannot be reached that way. Set `user_id` on anything you may later have to erase. Three kinds of content are rejected with 400, because none of them can be stored: invalid UTF-8; NULL bytes or unpaired UTF-16 surrogates, in keys or values; and numbers outside the storable numeric range. additionalProperties: {} source_id: type: [string, "null"] description: Reference to survey/form/ticket ID examples: - survey-123 maxLength: 255 pattern: '^[^\x00]*$' source_name: type: [string, "null"] description: Human-readable name examples: - Q1 NPS Survey maxLength: 255 pattern: '^[^\x00]*$' source_type: type: string description: Type of feedback source (e.g., survey, review, feedback_form). NULL bytes not allowed. examples: - survey minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' submission_id: type: string description: | Identifier for the logical submission this record belongs to (tenant-scoped). Required. Enables grouping multi-field submissions and idempotent ingestion. Unique per (tenant_id, submission_id, field_id). If a record has no logical submission, use e.g. field_id. examples: - "550e8400-e29b-41d4-a716-446655440000" minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' tenant_id: type: string description: Tenant/organization identifier for multi-tenancy. Required. U+FFFD is not allowed (it stands in for invalid UTF-8 or an unpaired surrogate). examples: - org-123 minLength: 1 maxLength: 255 pattern: '^[^\x00\uFFFD]*$' user_id: type: string description: User ID (e.g., anonymous ID or email hash) examples: - user-abc-123 maxLength: 255 pattern: '^[^\x00]*$' value_boolean: type: boolean description: For yes/no questions examples: - true value_date: type: string description: For date responses. Must be between 1970-01-01 and 2080-12-31. format: date-time value_number: type: number description: For ratings, NPS scores, numeric responses. Must be between -1e15 and +1e15. format: double minimum: -1000000000000000 maximum: 1000000000000000 examples: - 9 value_text: type: [string, "null"] description: For open-ended text responses. Omit or null if not applicable. NULL bytes not allowed when present. examples: - Great service! pattern: '^[^\x00]*$' maxLength: 30000 value_id: type: [string, "null"] description: Stable id of the selected option in the source system (e.g. a survey choice id or matrix column id), stored alongside value_text so selected-choice answers keep a durable identity across label edits and languages. Opaque to Hub (not validated). Null for free-text/"other" answers, non-choice fields, and sources without option ids. NULL bytes not allowed when present. examples: - opt_very_satisfied pattern: '^[^\x00]*$' maxLength: 255 required: - source_type - field_id - field_type - submission_id - tenant_id InvalidParam: type: object additionalProperties: false description: A single invalid request parameter (RFC 9457 "invalid_params" extension member). required: - name - reason properties: name: type: string description: Dotted path to the offending request field, e.g. 'field_type' or 'items[0].field_type'. examples: - field_type reason: type: string description: Human- and agent-readable explanation of why the value is invalid, naming allowed values or constraints where applicable so the request can be corrected without guessing. examples: - "must be one of: text, categorical, nps, csat, ces, rating, number, boolean, date" ErrorModel: type: object additionalProperties: false description: RFC 9457 problem details (application/problem+json). Branch on the stable `code` member rather than on `title` or `type`. required: - title - status - code - request_id properties: code: type: string description: >- Stable, machine-readable error code; the primary value to branch on. service_unavailable indicates a feature or dependency is temporarily unavailable and the request may be retried later. tenant_write_conflict indicates the write conflicted with an in-progress tenant data purge (or vice versa) and may be retried later unchanged. All other codes are terminal until the request itself is changed. enum: - validation - bad_request - unauthorized - forbidden - not_found - conflict - tenant_write_conflict - method_not_allowed - content_too_large - service_unavailable - internal_server_error examples: - validation detail: type: string description: A human-readable explanation specific to this occurrence of the problem. examples: - One or more request parameters are invalid details: type: object additionalProperties: true description: Optional structured context for this problem, e.g. resource_type on a 404. instance: type: string description: A URI reference (typically the request path) identifying the specific occurrence of the problem. examples: - /v1/feedback-records invalid_params: type: array maxItems: 100 description: Field-level validation failures (RFC 9457 "invalid_params" extension). Present on validation errors. items: $ref: '#/components/schemas/InvalidParam' request_id: type: string description: Identifier for this request, also returned in the X-Request-ID response header; include it when reporting a problem. examples: - 0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b status: type: integer description: HTTP status code format: int64 examples: - 400 title: type: string description: A short, human-readable summary of the problem type. This value should not change between occurrences of the error. examples: - Bad Request type: type: string description: A URI reference identifying the problem type. format: uri examples: - https://hub.formbricks.com/problems/bad-request FeedbackRecordDetailData: allOf: - $ref: '#/components/schemas/FeedbackRecordData' - type: object required: - taxonomy FeedbackRecordData: type: object additionalProperties: false properties: collected_at: type: string description: When the feedback was collected format: date-time created_at: type: string description: When this record was created format: date-time field_id: type: string description: Identifier for the question/field field_label: type: string description: The actual question text field_group_id: type: string description: Stable identifier grouping related fields (for ranking, matrix, grid questions) field_group_label: type: string description: Human-readable question text for the group field_type: $ref: '#/components/schemas/FieldType' description: Type of field id: type: string format: uuid description: UUIDv7 primary key language: type: string description: ISO language code. NULL bytes not allowed. pattern: '^[^\x00]*$' metadata: type: [object, array, string, number, boolean, "null"] # Required alongside `array` in the union; any element is valid, since the union # exists to describe legacy rows rather than to constrain new writes. items: {} description: >- Arbitrary context stored with this record, returned with equivalent values (key order is normalized, and so are number exponents — `1e2` reads back as `100`). Omitted when the record has no metadata; a record explicitly stored with a JSON `null` returns `null`. An object is the supported shape and the only one the request schemas accept. The wider union is for reading: the column stores whatever JSON was written, so a record created before that convention can return an array or a scalar, and an object-only response schema would make a generated client reject it. sentiment: $ref: '#/components/schemas/SentimentValue' description: Sentiment polarity inferred from value_text (sentiment enrichment). Read-only; absent until the record is enriched. sentiment_score: type: number description: Signed sentiment polarity from -1.0 (very negative) to 1.0 (very positive) (sentiment enrichment). Read-only; absent until the record is enriched. format: double minimum: -1.0 maximum: 1.0 taxonomy: description: Read-time assignment in the active directory taxonomy. Present only on the single-record GET; null when the lookup is unavailable. anyOf: - $ref: '#/components/schemas/FeedbackRecordTaxonomyOutputBody' - type: 'null' emotions: type: array description: Emotions inferred from value_text (emotion enrichment); multi-label from a fixed set. Read-only; absent until the record is enriched, and never an empty array. minItems: 1 items: $ref: '#/components/schemas/EmotionValue' source_id: type: string description: Reference to survey/form/ticket ID source_name: type: string description: Human-readable name source_type: type: string description: Type of feedback source submission_id: type: string description: Identifier for the logical submission this record belongs to (required). pattern: '^[^\x00]*$' tenant_id: type: string description: Tenant/organization identifier. NULL bytes not allowed. pattern: '^[^\x00]*$' translation_lang_key: type: string description: BCP-47 target locale that value_text_translated was produced in (language enrichment). Read-only; absent until the record is enriched. maxLength: 35 updated_at: type: string description: When this record was last updated format: date-time user_id: type: string description: User ID (e.g., anonymous ID or email hash) value_boolean: type: boolean description: Boolean response value_date: type: string description: Date response format: date-time value_number: type: number description: Numeric response format: double value_text: type: string description: Text response. NULL bytes not allowed. pattern: '^[^\x00]*$' value_id: type: string description: Stable id of the selected option in the source system (e.g. a survey choice id), stored alongside value_text for durable option identity. Opaque to Hub. Absent for free-text/non-choice answers. NULL bytes not allowed. pattern: '^[^\x00]*$' value_text_translated: type: string description: value_text translated into the tenant's configured target language (language enrichment). Read-only; absent until the record is enriched. pattern: '^[^\x00]*$' required: - id - collected_at - created_at - updated_at - source_type - field_id - field_type - submission_id - tenant_id ListFeedbackRecordsOutputBody: type: object additionalProperties: false properties: data: type: array description: List of feedback records items: $ref: '#/components/schemas/FeedbackRecordData' limit: type: integer description: Limit used in query format: int64 next_cursor: type: string description: Opaque cursor for the next page (keyset paging). Present only when there may be more results. Use as the cursor query param for the next page. required: - data - limit CreateWebhookInputBody: type: object additionalProperties: false properties: url: type: string description: URL to receive webhook POSTs. Must be an HTTP or HTTPS URL. NULL bytes not allowed. minLength: 1 maxLength: 2048 format: uri pattern: '^https?://[^\x00]*$' example: "https://example.com/hub-events" signing_key: type: string description: Optional. If omitted, a key is auto-generated (whsec_...). Used to sign payloads (Standard Webhooks). When provided, max 255 characters; NULL bytes not allowed. maxLength: 255 pattern: '^[^\x00]*$' example: "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" enabled: type: boolean description: Whether the webhook is active (default true) tenant_id: type: string description: Tenant/organization identifier. Required for webhook isolation; NULL bytes not allowed. U+FFFD is not allowed (it stands in for invalid UTF-8 or an unpaired surrogate). minLength: 1 maxLength: 255 pattern: '^[^\x00\uFFFD]*$' example: "org-123" event_types: type: array description: | Event types this webhook subscribes to. Each value must be one of WebhookEventType, listed at most once. If empty, the webhook receives all event types. maxItems: 6 uniqueItems: true items: $ref: '#/components/schemas/WebhookEventType' required: - url - tenant_id ListWebhooksOutputBody: type: object additionalProperties: false properties: data: type: array description: List of webhooks (signing_key omitted for security) items: $ref: '#/components/schemas/WebhookPublicData' total: type: integer description: Total count matching filters (present when offset-based; omitted when using cursor) format: int64 limit: type: integer description: Limit used in query format: int64 offset: type: integer description: Offset used in query (present when offset-based; omitted when using cursor) next_cursor: type: string description: Opaque cursor for the next page (keyset paging). Present only when there may be more results. Use as the cursor query param for the next page. required: - data - limit UpdateWebhookInputBody: type: object additionalProperties: false properties: url: type: string description: New webhook URL. Must be an HTTP or HTTPS URL. NULL bytes not allowed. minLength: 1 maxLength: 2048 format: uri pattern: '^https?://[^\x00]*$' example: "https://example.com/hub-events" signing_key: type: string description: New signing key. NULL bytes not allowed. minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' example: "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" enabled: type: boolean description: Enable or disable the webhook tenant_id: type: string description: Omit to leave unchanged. Empty strings are rejected; webhooks cannot be global. U+FFFD is not allowed (it stands in for invalid UTF-8 or an unpaired surrogate). minLength: 1 maxLength: 255 pattern: '^[^\x00\uFFFD]*$' example: "org-123" event_types: type: array description: New list of event types (use empty array to clear). Each value must be one of WebhookEventType, listed at most once. maxItems: 6 uniqueItems: true items: $ref: '#/components/schemas/WebhookEventType' WebhookPublicData: type: object description: Webhook data for GET and LIST responses; signing_key is omitted for security additionalProperties: false properties: id: type: string format: uuid description: Webhook ID (UUID) url: type: string description: URL that receives webhook POSTs enabled: type: boolean description: Whether the webhook is active tenant_id: type: string description: Tenant/organization identifier event_types: type: array description: Event types this webhook subscribes to (empty = all) items: $ref: '#/components/schemas/WebhookEventType' created_at: type: string format: date-time description: When the webhook was created updated_at: type: string format: date-time description: When the webhook was last updated disabled_reason: type: [string, "null"] description: Read-only. Set by the system when the webhook was disabled. Omitted when null. disabled_at: type: [string, "null"] format: date-time description: Read-only. When the webhook was disabled. Omitted when null. required: - id - url - enabled - created_at - updated_at WebhookData: type: object additionalProperties: false properties: id: type: string format: uuid description: Webhook ID (UUID) url: type: string description: URL that receives webhook POSTs signing_key: type: string description: Key used to sign payloads (Standard Webhooks) enabled: type: boolean description: Whether the webhook is active tenant_id: type: string description: Tenant/organization identifier event_types: type: array description: Event types this webhook subscribes to (empty = all) items: $ref: '#/components/schemas/WebhookEventType' created_at: type: string format: date-time description: When the webhook was created updated_at: type: string format: date-time description: When the webhook was last updated disabled_reason: type: [string, "null"] description: Read-only. Set by the system when the webhook was disabled (e.g. after 410 Gone or max delivery failures). Omitted when null. disabled_at: type: [string, "null"] format: date-time description: Read-only. When the webhook was disabled. Omitted when null. Cleared when the webhook is re-enabled via PATCH. required: - id - url - signing_key - enabled - created_at - updated_at WebhookEventType: type: string description: Valid event type for webhook subscriptions and delivery payloads enum: - feedback_record.created - feedback_record.updated - feedback_record.deleted - webhook.created - webhook.updated - webhook.deleted DeletedIdsPayload: type: array description: | For feedback_record.deleted and webhook.deleted events only. Array of deleted resource IDs (UUID strings). Single-resource deletes send one ID; multi-record deletes send all deleted IDs. items: type: string format: uuid WebhookDeliveryPayload: type: object description: | Payload POSTed to your webhook URL when an event occurs. Sent with Content-Type application/json. Requests include Standard Webhooks headers for verification: webhook-id, webhook-signature, webhook-timestamp. Verify the signature using your webhook's signing_key (see Standard Webhooks spec). Outbound request timeout is 15 seconds (per Standard Webhooks recommendation). The webhook-id header is a stable identifier per event: the same value is sent for every delivery attempt (all endpoints and all retries) for that event. Use it as an idempotency key (e.g. store seen IDs for a short window and skip duplicate processing). additionalProperties: false properties: id: type: string format: uuid description: Stable event ID. Matches the webhook-id header and can be used as an idempotency key. type: type: string description: Event type that occurred enum: - feedback_record.created - feedback_record.updated - feedback_record.deleted - webhook.created - webhook.updated - webhook.deleted timestamp: type: string format: date-time description: When the event was published (ISO 8601) tenant_id: type: string description: Tenant/organization identifier for the event. data: description: | Event payload. Shape depends on event type: - feedback_record.created / feedback_record.updated: FeedbackRecordData (object). - feedback_record.deleted: DeletedIdsPayload (array of deleted feedback record IDs). - webhook.created / webhook.updated: WebhookData (object). - webhook.deleted: DeletedIdsPayload (array of deleted webhook IDs; one ID for single delete). oneOf: - $ref: '#/components/schemas/FeedbackRecordData' - $ref: '#/components/schemas/WebhookData' - $ref: '#/components/schemas/DeletedIdsPayload' changed_fields: type: array description: Present only for update events; list of field names that changed items: type: string required: - id - type - timestamp - tenant_id - data UpdateFeedbackRecordInputBody: type: object additionalProperties: false properties: language: type: string description: Update language. NULL bytes not allowed. maxLength: 10 pattern: '^[^\x00]*$' metadata: type: [object, "null"] description: >- Replaces the stored metadata object **wholesale** — it is not merged, so send every key you want to keep or the omitted ones are lost. See the create operation for the value rules and key conventions, including the same 400 rejections (invalid UTF-8, NULL bytes, unpaired surrogates, out-of-range numbers). additionalProperties: {} user_id: type: string description: User ID (e.g., anonymous ID or email hash) maxLength: 255 pattern: '^[^\x00]*$' value_boolean: type: boolean description: Update boolean response value_date: type: string description: Update date response. Must be between 1970-01-01 and 2080-12-31. format: date-time value_number: type: number description: Update numeric response. Must be between -1e15 and +1e15. format: double minimum: -1000000000000000 maximum: 1000000000000000 value_text: type: string description: Update text response. NULL bytes not allowed. pattern: '^[^\x00]*$' maxLength: 30000 value_id: type: string description: Update the stable id of the selected option in the source system (e.g. a survey choice id), stored alongside value_text for durable option identity. Opaque to Hub. NULL bytes not allowed. pattern: '^[^\x00]*$' maxLength: 255 FieldType: type: string description: | The type of a feedback field, which determines which value_* member carries the answer. Mirrors the field_type_enum database type; keep the three in sync. enum: - text - categorical - nps - csat - ces - rating - number - boolean - date SentimentValue: type: string description: | Sentiment polarity label inferred from value_text by the sentiment enrichment: the five ordinal levels plus a distinct "mixed". Server-generated and read-only. enum: - very_negative - negative - neutral - positive - very_positive - mixed EmotionValue: type: string description: | A single emotion label inferred from value_text by the emotion enrichment (the six Ekman basic emotions). Emotions are multi-label, so a record carries zero or more of these; "mixed" is not a label, it is two or more present at once. Server-generated and read-only. enum: - joy - anger - sadness - fear - surprise - disgust TaxonomyRunStatus: type: string description: Lifecycle state of a taxonomy run. Allowed transitions are pending -> running|failed|canceled and running -> succeeded|failed|canceled. enum: - pending - running - succeeded - failed - canceled TaxonomyRunFailureCode: type: string description: Machine-readable reason a taxonomy run failed or a prerequisite was not met. enum: - insufficient_data - service_unavailable - generation_failed - invalid_output - internal_error TaxonomyScopeType: type: string description: | Taxonomy input scope. `field` covers one (source_type, source_id, field_id) field scope. `directory` covers all text feedback records for the tenant/directory and must not include source_type, source_id, or field_id. enum: - field - directory TaxonomyNodeType: type: string description: Position of a node within the taxonomy tree. enum: - root - branch - leaf TaxonomyRunData: type: object additionalProperties: false description: A persisted taxonomy generation run. properties: id: type: string format: uuid scope_type: $ref: '#/components/schemas/TaxonomyScopeType' tenant_id: type: string source_type: type: string source_id: type: string description: Empty string is the canonical "no source" bucket. field_id: type: string field_label: type: string description: Human-readable field label; absent when unknown. status: $ref: '#/components/schemas/TaxonomyRunStatus' params: type: object additionalProperties: true description: Opaque run parameters recorded by Hub. metrics: type: object additionalProperties: true description: Opaque run metrics recorded by the taxonomy service. record_count: type: integer format: int64 embedding_count: type: integer format: int64 cluster_count: type: integer format: int64 node_count: type: integer format: int64 error: type: string description: Sanitized failure message; present on failed runs. error_code: $ref: '#/components/schemas/TaxonomyRunFailureCode' started_at: type: string format: date-time finished_at: type: string format: date-time created_at: type: string format: date-time updated_at: type: string format: date-time required: - id - scope_type - tenant_id - source_type - source_id - field_id - status - record_count - embedding_count - cluster_count - node_count - created_at - updated_at TaxonomyNodeData: type: object additionalProperties: false description: A node in a taxonomy tree. Non-root nodes have a parent; leaf nodes reference the cluster they summarize. properties: id: type: string format: uuid run_id: type: string format: uuid parent_id: type: string format: uuid description: Parent node ID; absent for the root node. cluster_id: type: string format: uuid description: Cluster this node summarizes; typically present on leaf nodes. node_type: $ref: '#/components/schemas/TaxonomyNodeType' label: type: string original_label: type: string description: Label as originally generated, before any rename. description: type: string level: type: integer format: int64 description: Depth in the tree; the root is level 0. sort_order: type: integer format: int64 metadata: type: object additionalProperties: true removed_at: type: string format: date-time description: Set when the node has been soft-removed. removed_by: type: string description: Actor that soft-removed the node. created_at: type: string format: date-time updated_at: type: string format: date-time children: type: array description: Child nodes, present when the tree is returned hierarchically. items: $ref: '#/components/schemas/TaxonomyNodeData' required: - id - run_id - node_type - label - level - sort_order - created_at - updated_at TaxonomyFieldOption: type: object additionalProperties: false description: A feedback field that can be used for taxonomy generation, with its available input counts. properties: tenant_id: type: string source_type: type: string source_id: type: string description: Empty string is the canonical "no source" bucket. source_name: type: string field_id: type: string field_label: type: string record_count: type: integer format: int64 description: Number of text feedback records in the scope. embedding_count: type: integer format: int64 description: Number of those records that have an embedding. embedding_failed_count: type: integer format: int64 description: Number of missing taxonomy embeddings whose latest bounded attempts failed transiently and can be retried. embedding_failed_terminal_count: type: integer format: int64 description: Number of missing taxonomy embeddings rejected permanently for the current model and record revision. required: - tenant_id - source_type - source_id - field_id - record_count - embedding_count - embedding_failed_count - embedding_failed_terminal_count TaxonomyFieldsOutputBody: type: object additionalProperties: false properties: data: type: array items: $ref: '#/components/schemas/TaxonomyFieldOption' required: - data EnrichmentTypeStatus: type: object additionalProperties: false description: One enrichment's progress for a tenant. When `enabled` is false, `eligible` and `done` are zero. properties: enabled: type: boolean description: Whether the enrichment is active for the tenant (deployment-configured and switched on / with a resolvable target language). eligible: type: integer format: int64 description: Feedback records that qualify for this enrichment. done: type: integer format: int64 description: Eligible records that have been enriched. failed: type: integer format: int64 description: | Eligible records whose last enrichment attempt gave up but which a retry could still rescue — a provider outage, a timeout. Counted only while the record is still un-enriched, so a later success removes it without any cleanup. failed_terminal: type: integer format: int64 description: | Eligible records whose enrichment will never succeed, because the outcome is a property of the record's own text — a content-policy block, a refusal, an input past the model's limit — or because the record is over that enrichment's configured input cap, which is refused before any provider call (`TRANSLATION_MAX_INPUT_BYTES`). Retrying these cannot help; they resolve only if the text changes. disabled_reason: type: string enum: - not_configured - switched_off - no_target_language description: | Which gate switched the enrichment off. Present exactly when `enabled` is false, and absent otherwise. `not_configured` — the deployment has no provider/model for this enrichment, so it is off for every tenant and only an operator can fix it. `switched_off` — the tenant turned it off (sentiment and emotions only). `no_target_language` — translation is configured but neither the tenant's `target_language` nor the deployment default resolves (translation only; it has no on/off switch, so an absent target is its off state). required: - enabled - eligible - done - failed - failed_terminal EnrichmentRetryInputBody: type: object additionalProperties: false properties: enrichments: type: array maxItems: 3 items: type: string enum: - translation - sentiment - emotions description: | Which enrichments to clear. Omit or leave empty for all of them, which is the usual call. An unknown name is rejected rather than ignored, so a typo cannot come back as a successful no-op. A list longer than the three names is rejected too. EnrichmentRetryResult: type: object additionalProperties: false properties: enrichment: type: string enum: - translation - sentiment - emotions outcome: type: string enum: - cleared - cooling_down - disabled description: | `cleared` — the markers were removed and the records will be picked up by the next reconcile sweep. `cooling_down` — this enrichment was cleared too recently; see `retry_after_seconds`. `disabled` — the enrichment is not running for this tenant, so clearing would queue work the worker skips; see `disabled_reason`. cleared: type: integer format: int64 description: | How many records had a terminal marker removed. Zero with outcome `cleared` is a real answer: nothing was permanently failed. retry_after_seconds: type: integer format: int64 description: Present only for `cooling_down`. How long until this enrichment may be cleared again. disabled_reason: type: string enum: - not_configured - switched_off - no_target_language description: | Present only for `disabled`. Same values the enrichment-status endpoint uses, so a consumer needs one vocabulary rather than two. required: - enrichment - outcome - cleared EnrichmentRetryOutputBody: type: object additionalProperties: false properties: tenant_id: type: string results: type: array items: $ref: '#/components/schemas/EnrichmentRetryResult' required: - tenant_id - results EnrichmentStatusOutputBody: type: object additionalProperties: false description: A tenant's enrichment progress across the record-level enrichments. Counts are directory-level totals. properties: tenant_id: type: string as_of: type: string format: date-time description: | When the counts were computed (UTC), not when the response was serialized. The endpoint is polled, so two responses are enough to derive throughput and an ETA client-side from the change in `done` over the change in `as_of` — the Hub computes neither. translation: $ref: '#/components/schemas/EnrichmentTypeStatus' sentiment: $ref: '#/components/schemas/EnrichmentTypeStatus' emotions: $ref: '#/components/schemas/EnrichmentTypeStatus' required: - tenant_id - as_of - translation - sentiment - emotions CreateTaxonomyRunInputBody: type: object additionalProperties: false description: | Request to start a manual taxonomy run. Omit scope_type or set it to `field` for the existing field-scoped behavior. Set scope_type to `directory` with only tenant_id to generate one taxonomy over all text feedback in the directory. properties: scope_type: $ref: '#/components/schemas/TaxonomyScopeType' tenant_id: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00\uFFFD]*$' source_type: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' description: Required for field scope; omit for directory scope. source_id: type: string maxLength: 255 pattern: '^[^\x00]*$' description: Optional for field scope; empty or omitted is the canonical "no source" bucket. Omit for directory scope. field_id: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' description: Required for field scope; omit for directory scope. field_label: type: string maxLength: 2048 pattern: '^[^\x00]*$' description: Optional human-readable field label. actor_id: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' description: Optional identifier of the actor starting the run. required: - tenant_id CreateTaxonomyRunOutputBody: type: object additionalProperties: false properties: run: $ref: '#/components/schemas/TaxonomyRunData' in_progress: type: boolean description: True when an existing pending/running run for the scope was returned instead of starting a new one. required: - run - in_progress ListTaxonomyRunsOutputBody: type: object additionalProperties: false properties: data: type: array items: $ref: '#/components/schemas/TaxonomyRunData' required: - data TaxonomyTreeOutputBody: type: object additionalProperties: false properties: run: $ref: '#/components/schemas/TaxonomyRunData' root: description: Root node of the tree, or null when the run has no nodes. oneOf: - $ref: '#/components/schemas/TaxonomyNodeData' - type: "null" required: - run - root FeedbackRecordTaxonomyPathNode: type: object additionalProperties: false properties: id: type: string format: uuid label: type: string level: type: integer format: int64 node_type: type: string enum: [branch, leaf] required: - id - label - level - node_type FeedbackRecordTaxonomyOutputBody: type: object additionalProperties: false properties: status: type: string enum: [classified, unclassified, no_active_taxonomy] run_id: description: Active directory taxonomy run ID, or null if none exists. oneOf: - type: string format: uuid - type: "null" path: type: array description: Current visible non-root nodes in root-to-leaf order; empty unless classified. items: $ref: '#/components/schemas/FeedbackRecordTaxonomyPathNode' required: - status - run_id - path RenameTaxonomyNodeInputBody: type: object additionalProperties: false description: Request to rename a taxonomy node. properties: tenant_id: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00\uFFFD]*$' actor_id: type: string minLength: 1 maxLength: 255 pattern: '^[^\x00]*$' label: type: string minLength: 1 maxLength: 2048 pattern: '^[^\x00]*$' description: New node label. required: - tenant_id - actor_id - label TaxonomyNodeRecordsOutputBody: type: object additionalProperties: false properties: data: type: array items: $ref: '#/components/schemas/FeedbackRecordData' limit: type: integer format: int64 description: The applied maximum number of records. required: - data - limit TaxonomyNodeRecordCount: type: object additionalProperties: false properties: node_id: type: string format: uuid description: Taxonomy node ID. example: "019f177f-9aa3-705e-8195-cea2aa187268" record_count: type: integer format: int64 description: Number of feedback records in the node's subtree. example: 42 required: - node_id - record_count TaxonomyRecordCountsOutputBody: type: object additionalProperties: false properties: counts: type: array description: Per-node feedback-record counts, one entry per visible node. items: $ref: '#/components/schemas/TaxonomyNodeRecordCount' required: - counts