{
  "openapi": "3.1.0",
  "info": {
    "title": "Sentriment API",
    "version": "1.0.0",
    "description": "Ingest qualitative feedback and read AI-generated clusters. Authenticate with `Authorization: Bearer sk_…` (server) or `pk_…` (widget; origin-checked). Errors follow RFC 7807 (application/problem+json)."
  },
  "servers": [
    {
      "url": "https://app.sentriment.com"
    }
  ],
  "paths": {
    "/api/v1/feedback": {
      "get": {
        "summary": "List feedback items (newest first)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "cluster",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sentiment",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "positive",
                "neutral",
                "negative"
              ]
            }
          },
          {
            "name": "source",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "api",
                "widget",
                "csv",
                "appstore",
                "intercom",
                "zendesk",
                "call"
              ]
            }
          },
          {
            "name": "meta",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "style": "form",
            "explode": true,
            "description": "Filter on custom properties as key:value (e.g. test:paywall-v2); repeat to AND several."
          },
          {
            "name": "before",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Cursor from nextBefore"
          }
        ],
        "responses": {
          "200": {
            "description": "Items + nextBefore cursor"
          }
        }
      },
      "post": {
        "summary": "Submit a feedback item",
        "description": "Returns 202 immediately; processing (redaction beyond the built-in pass, embedding, clustering, sentiment) is asynchronous. Supply an `Idempotency-Key` header to make retries safe.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 128
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "text": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 10000
                  },
                  "userId": {
                    "type": "string",
                    "maxLength": 256
                  },
                  "email": {
                    "type": "string",
                    "maxLength": 320
                  },
                  "source": {
                    "type": "string",
                    "enum": [
                      "api",
                      "widget"
                    ]
                  },
                  "metadata": {
                    "type": "object",
                    "additionalProperties": {}
                  },
                  "createdAt": {
                    "type": "string",
                    "format": "date-time",
                    "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
                  }
                },
                "required": [
                  "text"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Duplicate Idempotency-Key; original item returned"
          },
          "202": {
            "description": "Accepted for processing",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "pending"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Validation error (problem+json)"
          },
          "401": {
            "description": "Missing/invalid key (problem+json)"
          },
          "403": {
            "description": "Origin not allowed for widget key (problem+json)"
          },
          "429": {
            "description": "Rate limited (problem+json, Retry-After header)"
          }
        }
      }
    },
    "/api/v1/search": {
      "get": {
        "summary": "Semantic search over feedback",
        "description": "Embeds the query and ranks feedback by meaning (cosine similarity), not keywords. 60 requests/min per key.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "maxLength": 500
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "maximum": 50,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ranked results with similarity scores"
          },
          "400": {
            "description": "Missing/invalid q (problem+json)"
          },
          "429": {
            "description": "Rate limited (problem+json)"
          }
        }
      }
    },
    "/api/v1/users/identify": {
      "post": {
        "summary": "Attach traits to an end user (Mixpanel-style identify)",
        "description": "Shallow-merges traits (max 32 keys, 8KB). Accepts secret keys, or widget keys from an allowed origin. Traits power segment filters (?trait=plan:pro) and cluster segment breakdowns. Outcome traits (e.g. churned: \"true\", mrr) unlock impact correlation: every theme and Ask answer shows how the users behind it churn and how much revenue is at stake — traits merge, so flagging a churn after the fact retroactively correlates their old feedback.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "userId",
                  "traits"
                ],
                "properties": {
                  "userId": {
                    "type": "string",
                    "maxLength": 256
                  },
                  "traits": {
                    "type": "object",
                    "additionalProperties": {
                      "oneOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "number"
                        },
                        {
                          "type": "boolean"
                        }
                      ]
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Merged traits returned"
          },
          "429": {
            "description": "Rate limited (120/min per key)"
          }
        }
      }
    },
    "/api/v1/users/{externalUserId}": {
      "get": {
        "summary": "Fetch one end user (GDPR Art. 15 access)",
        "description": "Profile traits plus the feedback we hold for this user (up to 200 items).",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "externalUserId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Profile + feedback"
          },
          "404": {
            "description": "Unknown user"
          }
        }
      },
      "delete": {
        "summary": "Delete all feedback from one end user (GDPR Art. 17)",
        "description": "Queues a hard delete of every feedback item with this userId. Returns 202 with a request id.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "externalUserId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Deletion queued"
          }
        }
      }
    },
    "/api/v1/ping": {
      "get": {
        "summary": "Validate a key / connectivity check",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Key is valid; returns project + key type"
          }
        }
      }
    },
    "/api/v1/webhooks": {
      "get": {
        "summary": "List webhook endpoints",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Endpoints with delivery health"
          }
        }
      },
      "post": {
        "summary": "Create a webhook endpoint",
        "description": "Events: feedback.processed, cluster.spiking, cluster.regressed, cluster.resolved, user.health_changed, question.verdict_changed. The signing secret is returned exactly once. Deliveries carry X-Sentriment-Signature: t=<unix>,v1=<hex hmac_sha256(secret, `${t}.${body}`)>; retried 5x with backoff. An endpoint is auto-disabled only after it has been failing continuously for 15 minutes — a burst of failures cannot disable a healthy endpoint that is merely rate-limiting. GET returns failingSince (start of the current failure streak, null when healthy).",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url",
                  "events"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri"
                  },
                  "events": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "feedback.processed",
                        "cluster.spiking",
                        "cluster.regressed",
                        "cluster.resolved",
                        "user.health_changed",
                        "question.verdict_changed"
                      ]
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created; includes the one-time secret"
          }
        }
      }
    },
    "/api/v1/webhooks/{id}": {
      "patch": {
        "summary": "Pause or resume a webhook endpoint",
        "description": "Set active=false to stop deliveries without losing the endpoint or its signing secret. Set active=true to resume, which also clears the failure streak and its clock — this is how an auto-disabled endpoint is brought back, and why the first hiccup afterwards cannot immediately disable it again.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "active"
                ],
                "properties": {
                  "active": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated endpoint"
          },
          "404": {
            "description": "No such endpoint in this project"
          }
        }
      },
      "delete": {
        "summary": "Delete a webhook endpoint",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted"
          }
        }
      }
    },
    "/api/v1/clusters/{id}": {
      "get": {
        "summary": "Cluster detail with recent items",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "maximum": 100,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cluster + items"
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    },
    "/api/v1/feedback/{id}": {
      "delete": {
        "summary": "Delete one feedback item",
        "description": "Hard delete; cluster counters are recomputed. Returns 204.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted"
          },
          "404": {
            "description": "Not found"
          }
        }
      },
      "get": {
        "summary": "Fetch one feedback item with analysis results",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Feedback item"
          },
          "404": {
            "description": "Not found (problem+json)"
          }
        }
      }
    },
    "/api/v1/clusters": {
      "get": {
        "summary": "List active clusters for the project",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Cluster list"
          }
        }
      }
    },
    "/api/v1/transcripts": {
      "post": {
        "summary": "Send a call transcript for extraction",
        "description": "Secret keys only. One call per request; returns 202 as soon as the row is recorded and the call is counted (one item per 500 words, minimum 2), before any model runs. The transcript is read once and never stored: the customer's own words come out as verbatim quotes, each an ordinary feedback item with source `call`; the rep's speech, the summary and every name are dropped. external_id is the idempotency key: a repeat returns 200 with the existing row and counts nothing. Exactly one of `segments` or `text`. Speaker mapping: `me`/`them` literal; any other label equal (case-insensitive) to owner_name, to its first name, or to the local part of owner_email is `me`; other labels are `them`; text with no labels at all is recorded as skipped (`no_quotable_speech`), never guessed. is_backfill (history: joins themes and scores health, never alerts) when started_at is more than 48 hours before receipt; a missing started_at counts as now. 60 requests/min per key; bodies up to 2 MB.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TranscriptCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "external_id already recorded in this project; the existing row, nothing re-metered",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TranscriptAccepted"
                }
              }
            }
          },
          "202": {
            "description": "New row recorded and counted; extraction runs asynchronously",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TranscriptAccepted"
                }
              }
            }
          },
          "400": {
            "description": "invalid-request: validation failed; detail names the field, or the line count when the text could not be read (problem+json)"
          },
          "401": {
            "description": "Missing/invalid key, or a widget (pk_) key (problem+json)"
          },
          "413": {
            "description": "payload-too-large: body over 2 MB (problem+json)"
          },
          "429": {
            "description": "Rate limited, 60/min per key (problem+json, Retry-After header)"
          },
          "500": {
            "description": "Nothing recorded; safe to retry with the same external_id (problem+json)"
          }
        }
      }
    },
    "/api/v1/transcripts/{id}": {
      "get": {
        "summary": "Fetch one transcript's status and counts",
        "description": "Project-scoped. No text is returned because none is kept. signal_count is the number of feedback items extracted from the call.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Transcript",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Transcript"
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid key, or a widget (pk_) key (problem+json)"
          },
          "404": {
            "description": "not-found: no such transcript in this project (problem+json)"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer"
      }
    },
    "schemas": {
      "TranscriptAttendee": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string",
            "format": "email"
          },
          "name": {
            "type": "string",
            "description": "Used to redact the transcript before it is read, then dropped. Never stored."
          },
          "external": {
            "type": "boolean",
            "description": "Defaults to: the email domain differs from owner_email's domain. At least one external attendee is required for the call to be analysed."
          }
        }
      },
      "TranscriptSegment": {
        "type": "object",
        "required": [
          "speaker",
          "text"
        ],
        "properties": {
          "speaker": {
            "type": "string",
            "description": "\"me\", \"them\", or the speaker label as written (mapped via owner_name / owner_email)."
          },
          "text": {
            "type": "string",
            "minLength": 1,
            "maxLength": 20000,
            "description": "One turn of speech."
          },
          "start_sec": {
            "type": "number",
            "minimum": 0,
            "description": "Seconds from the start of the call."
          }
        }
      },
      "TranscriptCreate": {
        "type": "object",
        "required": [
          "external_id"
        ],
        "properties": {
          "external_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "The caller's stable id for the call (e.g. the notetaker's meeting id). Idempotency key."
          },
          "title": {
            "type": "string",
            "maxLength": 200,
            "description": "Stored with personal names removed."
          },
          "started_at": {
            "type": "string",
            "format": "date-time",
            "description": "Refused when more than 24 hours in the future."
          },
          "duration_sec": {
            "type": "integer",
            "minimum": 0
          },
          "language": {
            "type": "string",
            "description": "ISO 639-1",
            "minLength": 2,
            "maxLength": 2
          },
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 500,
            "description": "https link back to the call in the source tool."
          },
          "owner_email": {
            "type": "string",
            "format": "email",
            "description": "The person who ran the notetaker (the rep)."
          },
          "owner_name": {
            "type": "string",
            "description": "How the rep is labelled in the transcript, for speaker mapping."
          },
          "folder": {
            "type": "string",
            "maxLength": 100
          },
          "attendees": {
            "type": "array",
            "maxItems": 50,
            "items": {
              "$ref": "#/components/schemas/TranscriptAttendee"
            }
          },
          "segments": {
            "type": "array",
            "maxItems": 5000,
            "items": {
              "$ref": "#/components/schemas/TranscriptSegment"
            }
          },
          "text": {
            "type": "string",
            "description": "The whole transcript, read into at most 5000 segments. format \"text\" accepts lines like \"Them: …\", \"Me: …\", \"[00:14:32] Them: …\", \"Alice: …\"; format \"vtt\" is WebVTT with optional <v Name> voice tags."
          },
          "format": {
            "type": "string",
            "enum": [
              "text",
              "vtt"
            ],
            "default": "text",
            "description": "Only with text."
          }
        },
        "oneOf": [
          {
            "required": [
              "segments"
            ],
            "not": {
              "required": [
                "text"
              ]
            }
          },
          {
            "required": [
              "text"
            ],
            "not": {
              "required": [
                "segments"
              ]
            }
          }
        ]
      },
      "TranscriptAccepted": {
        "type": "object",
        "required": [
          "id",
          "status",
          "metered_items",
          "word_count"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "extracting",
              "done",
              "failed",
              "skipped",
              "plan_blocked"
            ]
          },
          "metered_items": {
            "type": "integer",
            "description": "Items counted for this call: one per 500 words, minimum 2."
          },
          "word_count": {
            "type": "integer"
          },
          "skip_reason": {
            "type": "string",
            "enum": [
              "too_short",
              "too_long",
              "no_quotable_speech",
              "no_external_attendee",
              "title_excluded",
              "folder_excluded",
              "internal_call"
            ],
            "description": "Present when status is skipped. The door's own reasons are not counted; internal_call is the model's judgement after reading and is."
          }
        }
      },
      "Transcript": {
        "type": "object",
        "required": [
          "id",
          "external_id",
          "provider",
          "status",
          "metered_items",
          "word_count",
          "signal_count",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "external_id": {
            "type": "string"
          },
          "provider": {
            "type": "string",
            "enum": [
              "granola",
              "api",
              "upload"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "extracting",
              "done",
              "failed",
              "skipped",
              "plan_blocked"
            ]
          },
          "skip_reason": {
            "type": "string",
            "enum": [
              "too_short",
              "too_long",
              "no_quotable_speech",
              "no_external_attendee",
              "title_excluded",
              "folder_excluded",
              "internal_call"
            ],
            "description": "Present when status is skipped."
          },
          "failure_reason": {
            "type": "string",
            "description": "A code (e.g. provider_5xx, schema_refused), never a message. Present when status is failed."
          },
          "metered_items": {
            "type": "integer"
          },
          "word_count": {
            "type": "integer"
          },
          "signal_count": {
            "type": "integer",
            "description": "Feedback items extracted from this call."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "extracted_at": {
            "type": "string",
            "format": "date-time",
            "description": "Present once extraction finished."
          }
        }
      }
    }
  }
}
