Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.0.3
info:
title: ClosedLoop AI Public API
version: "1.8.0"
x-logo:
url: https://app.closedloop.sh/favicon.svg
altText: ClosedLoop AI
description: |
# ClosedLoop AI Public API
Programmatic access to your team's **product insights**, the structured intelligence
ClosedLoop AI extracts from customer conversations (Gong, Fireflies, Slack, …) and
structured feedback (surveys, webhooks).
Pull insights, themes, features, customer context and analytics into your own tools.
## Two ways to integrate
| | **This REST API** | **MCP** (live today) |
|---|---|---|
| Best for | server-to-server, dashboards, ETL, scripts | AI assistants (Claude, Cursor, …), agents |
| Shape | resource endpoints, JSON | tool calls over the Model Context Protocol |
| Auth | `X-API-Key` header | OAuth |
Most agentic use cases are already covered by MCP. Reach for this REST API when you
want plain HTTP resources.
## Base URLs (region-scoped)
Your API key belongs to a **team**, and each team lives in exactly one region. Use the
base URL for **your** region. A key issued in the EU only works against the EU host.
| Region | Base URL |
|---|---|
| United States | `https://api.closedloop.sh/v1` |
| European Union | `https://eu.api.closedloop.sh/v1` |
## Authentication
Create a key in **Settings → API Keys** in the app, then send it on every request:
```
X-API-Key: clai_live_xxxxxxxxxxxxxxxxxxxx
```
Keys are **team-scoped**: every response is automatically limited to your team's data.
Never expose a key in client-side code.
## Conventions
- **Read-only**: every v1 endpoint is a `GET`; nothing is mutated. Feedback is ingested
through your connected integrations, not this API.
- **Pagination**: list endpoints accept `limit` (default 50, max 200) and `offset`.
`/insights` also supports fixed-watermark cursor export with `updated_since`.
- **Errors**: non-2xx responses return a JSON `{ error, code, hint? }` body. User-safe
messages only; never raw internals.
- **Rate limits**: the public API has no general fixed per-key quota. Semantic theme
search uses implementation-defined protective rate and concurrency safeguards.
Clients must handle `429` and honor `Retry-After`; these safeguards are enforced
independently by API replica and are not a fleet-wide numeric quota.
- **Linking insights and context**: both carry the same resolved `customer_id`, so you
can line up an insight with the context (churn, competitor, satisfaction) for the same
customer. Filter either list by `customer_id`.
- **Time anchors**: timestamps are ISO 8601 (UTC).
contact:
name: ClosedLoop AI Support
email: support@closedloop.sh
url: https://closedloop.sh
license:
name: Proprietary (ClosedLoop Labs LLC)
url: https://closedloop.sh/terms
servers:
- url: https://api.closedloop.sh/v1
description: United States (production)
- url: https://eu.api.closedloop.sh/v1
description: European Union (production)
security:
- ApiKeyAuth: []
tags:
- name: Insights
description: |
Individual **product insights**: structured, AI-processed feedback items
(pain point, severity, workaround, competitor gap, evidence).
- name: Products
description: The current product, product-area, and feature-area vocabulary for the team.
- name: Themes
description: |
**Themes**: top-level problem clusters of related insights, RIC-scored. The
"what should we build?" surface. Each theme rolls up one or more buildable features.
- name: Features
description: |
**Features**: the buildable, shippable children of a theme. Filter by `theme_id`.
Same theme → features hierarchy you see on the roadmap.
- name: Customers
description: The people and accounts who gave feedback, with CRM context.
- name: Context
description: |
**Customer context**, the strategic intelligence behind your customers: churn
reasons, competitive mentions, satisfaction, pricing perception and more, surfaced
from conversations. This is the `/context` surface in the app. Each record resolves
to a customer (`customer_id`), so it lines up with insights for the same customer.
- name: Competitors
description: |
Competitive pressure derived from your customers' own words. Competitors with a
**mention trend over time** (not a static total), and a searchable feed of the exact
mentions, each showing the customer behind it.
- name: Analytics
description: Trends, cohorts and facet counts over the full dataset.
- name: Integrations
description: Connected data sources for your team.
- name: Usage
description: Credit consumption and usage records for billing transparency.
- name: Meta
description: Service metadata.
x-tagGroups:
- name: Prioritization
tags: [Insights, Products, Themes, Features]
- name: Customers & Context
tags: [Customers, Context, Competitors]
- name: Reporting
tags: [Analytics]
- name: Account
tags: [Integrations, Usage]
- name: Meta
tags: [Meta]
paths:
/insights:
get:
operationId: listInsights
tags: [Insights]
summary: List / search insights
x-mint:
metadata:
description: "Search processed product insights by customer, severity, product scope, date, or text, with offset or fixed-watermark incremental pagination."
description: |
Search processed product insights. Filter by category, severity, customer,
product, product feature, product area, feature area, date range, or a
free-text query.
Offset pagination is the default. For a stable incremental export, start with
`updated_since`; then follow `next_cursor` using only `cursor` and optionally
`limit`. The fixed export window is `updated_since < updated_at <= sync_until`.
Product filtering returns insights with a recorded product association. Some
insights remain unassigned when ClosedLoop AI cannot determine a reliable
automatic match, so filtered results may not include every potentially relevant
insight. Responses expose the IDs accepted by all four product-scope filters:
`products[].id`, `product_features[].id`, `product_area_id`, and `feature_area_id`.
parameters:
- $ref: '#/components/parameters/Query'
- name: category
in: query
schema: { type: string, example: "bug" }
description: Feedback category (e.g. bug, feature request, usability).
- name: severity
in: query
schema: { type: string, enum: [critical, high, medium, low] }
- name: customer_id
in: query
schema: { type: string, format: uuid }
description: Filter to insights from one customer (same key as on context).
- $ref: '#/components/parameters/ProductId'
- $ref: '#/components/parameters/ProductFeatureId'
- $ref: '#/components/parameters/ProductAreaId'
- $ref: '#/components/parameters/FeatureAreaId'
- $ref: '#/components/parameters/DateFrom'
- $ref: '#/components/parameters/DateTo'
- name: updated_since
in: query
schema: { type: string, format: date-time }
example: "2026-05-01T00:00:00Z"
description: |
Start a fixed-watermark incremental export after this insight-row update
timestamp. Cannot be combined with `offset` or `cursor`.
Must be a timezone-qualified ISO 8601 date-time - the offset is
required, so `2026-05-01T00:00:00` (no `Z` or `+hh:mm`) is rejected
with `400 VALIDATION_ERROR`, as is a date-only value.
- name: cursor
in: query
schema: { type: string, maxLength: 8192 }
description: |
Opaque continuation cursor returned by the prior incremental page. A cursor
request may include only `cursor` and `limit`. It is bound to the team, exact
API key, filters, and watermark.
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: A page of insights
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/Insight' }
pagination:
oneOf:
- $ref: '#/components/schemas/Pagination'
- $ref: '#/components/schemas/CursorPagination'
examples:
offset:
summary: Offset page
value:
data:
- id: "123e4567-e89b-42d3-a456-426614174000"
title: "Scheduled exports are needed for reporting"
category: "feature request"
severity: "medium"
customer_id: "c0ffee00-0000-4000-8000-000000000001"
customer_name: "Acme Co"
source: "gong_call_abc123"
source_date: "2026-05-14T10:30:00Z"
created_at: "2026-05-14T10:32:00Z"
updated_at: "2026-05-15T08:12:00Z"
feature_area_id: "523e4567-e89b-42d3-a456-426614174004"
feature_area_name: "Exports"
product_area_id: "423e4567-e89b-42d3-a456-426614174003"
product_area_name: "Operations"
products:
- id: "223e4567-e89b-42d3-a456-426614174001"
name: "Core Platform"
status: "active"
product_features:
- id: "323e4567-e89b-42d3-a456-426614174002"
title: "Scheduled exports"
product_id: "223e4567-e89b-42d3-a456-426614174001"
pagination: { total: 1, limit: 50, offset: 0 }
incremental:
summary: Incremental page
value:
data: []
pagination:
limit: 200
has_more: false
next_cursor: null
sync_until: "2026-05-14T10:30:00.123456Z"
'400': { $ref: '#/components/responses/ValidationError' }
'401': { $ref: '#/components/responses/Unauthorized' }
'503': { $ref: '#/components/responses/InsightsUnavailable' }
/insights/{id}:
get:
operationId: getInsight
tags: [Insights]
summary: Get an insight
description: |
Full insight detail with evidence: pain point, workaround, competitor gap,
willingness to pay, feature area, product associations, and the verbatim quote.
parameters:
- $ref: '#/components/parameters/PathId'
responses:
'200':
description: Insight detail
content:
application/json:
schema: { $ref: '#/components/schemas/InsightDetail' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/AuthenticationUnavailable' }
/products:
get:
operationId: listProducts
tags: [Products]
summary: List products
description: |
Current product vocabulary for the authenticated team. Draft and active products
are returned by default. Use these immutable IDs with `product_id` filters.
parameters:
- name: include_retired
in: query
schema: { type: boolean, default: false }
description: Include retired products as well as draft and active products.
responses:
'200':
description: Product vocabulary
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
type: array
items: { $ref: '#/components/schemas/Product' }
example:
data:
- id: "123e4567-e89b-42d3-a456-426614174000"
name: "Core Platform"
description: "Shared platform capabilities"
aliases: ["Platform"]
key_features: ["Permissions", "Exports"]
url: "https://example.com/products/core"
status: "active"
source: "manual"
sort_order: 0
created_at: "2026-04-02T00:00:00Z"
updated_at: "2026-05-01T00:00:00Z"
'401': { $ref: '#/components/responses/Unauthorized' }
'503': { $ref: '#/components/responses/AuthenticationUnavailable' }
/areas:
get:
operationId: listAreas
tags: [Products]
summary: List product and feature areas
x-mint:
metadata:
description: "List active product and feature areas with all-time insight totals and earliest feedback dates, or include archived areas when needed."
description: |
Current product-area and feature-area vocabulary. Each area includes its all-time
insight total and earliest feedback date, so an empty filtered result can be
distinguished from a scope with little or no coverage. Active areas are returned
by default.
parameters:
- name: include_archived
in: query
schema: { type: boolean, default: false }
description: Include archived product areas and feature areas.
responses:
'200':
description: Nested area vocabulary with all-time coverage totals
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
type: array
items: { $ref: '#/components/schemas/ProductArea' }
example:
data:
- id: "223e4567-e89b-42d3-a456-426614174000"
name: "Operations"
description: "Operational workflows"
status: "active"
sort_order: 0
insight_count: 84
feedback_since: "2026-01-12T09:00:00Z"
feature_areas:
- id: "323e4567-e89b-42d3-a456-426614174000"
name: "Exports"
description: "Data export workflows"
status: "active"
sort_order: 0
insight_count: 84
feedback_since: "2026-01-12T09:00:00Z"
'401': { $ref: '#/components/responses/Unauthorized' }
'503': { $ref: '#/components/responses/AuthenticationUnavailable' }
/coverage:
get:
operationId: getCoverage
tags: [Analytics]
summary: Check evidence coverage
x-mint:
metadata:
description: "Check whether a product or area scope has enough evidence for reliable analysis, including coverage dates and windowed insight totals."
description: |
Classifies whether evidence in a product or area scope is informative, too thin,
or predates team coverage. The current density threshold is 50 distinct insights.
`policy_version` makes future policy changes explicit.
`scope.feedback_since` and `scope.all_time_insight_count` ignore the requested date
window but keep the product and area filters. Windowed counts apply every filter.
parameters:
- $ref: '#/components/parameters/ProductId'
- $ref: '#/components/parameters/ProductFeatureId'
- $ref: '#/components/parameters/ProductAreaId'
- $ref: '#/components/parameters/FeatureAreaId'
- $ref: '#/components/parameters/DateFrom'
- $ref: '#/components/parameters/DateTo'
responses:
'200':
description: Coverage verdict and supporting totals
content:
application/json:
schema: { $ref: '#/components/schemas/Coverage' }
example:
verdict: "informative"
reason: "density_threshold_met"
reads_as: "84 insights across 6 themes cover the requested scope, so a weak match is worth noticing — but it still is not proof that nobody wants it."
policy_version: "1"
density_threshold: 50
filters:
product_id: null
product_feature_id: null
product_area_id: "223e4567-e89b-42d3-a456-426614174000"
feature_area_id: null
date_range: { from: null, to: null }
scope:
insight_count: 84
all_time_insight_count: 84
theme_count: 6
customer_count: 31
deal_blocker_count: 4
feedback_since: "2026-01-12T09:00:00Z"
team:
insight_count: 508
feedback_since: "2025-11-03T12:00:00Z"
'400': { $ref: '#/components/responses/ValidationError' }
'401': { $ref: '#/components/responses/Unauthorized' }
'503': { $ref: '#/components/responses/AuthenticationUnavailable' }
/themes:
get:
operationId: listThemes
tags: [Themes]
summary: List / search themes
x-mint:
metadata:
description: "List or search top-level product themes by status, product scope, evidence date, or meaning, with deterministic sorting and pagination."
description: |
Top-level problem clusters, RIC-scored, most impactful first. Each theme rolls up
one or more buildable features. Filter by status, product scope, date, or search
text. Evidence filters recompute `insight_count`, `unique_customer_count`, and
`deal_blocker_count` from distinct canonical evidence across the theme and its
active features; themes with no matching evidence are omitted.
Search is lexical by default: the complete `q` value must occur as one
case-insensitive substring in the theme title or description. Set
`search_mode=semantic` to match by meaning instead. Semantic results are ordered
by similarity descending, then immutable theme ID; this relevance order overrides
`sort`. If query embedding generation is unavailable, the request returns `503`
by default. Set `allow_fallback=true` to permit lexical fallback. Always inspect
the response's `search_mode` and `min_similarity` fields to determine what was
applied.
By default, merged themes are excluded. Set `include_retired=true` to add valid
merged themes to the selected status set. Each returned merged theme has a
`replacement_theme_id` that points directly to the final surviving theme; callers
never need to follow a chain. Search and other filters still apply to the retired
theme's own stored record and evidence.
The default response is CURATED, not a census: each team has an adaptive
size floor, computed nightly from that team's own distribution, and very
small items below it are excluded — the same set the ClosedLoop AI app
shows. `pagination.total` counts the curated set. Set `show_all=true` for
every row regardless of size. Teams with too little data to compute a
floor are never filtered.
A theme's size is measured through its live features, so the floor cannot
be evaluated for a finished theme. `status=shipped` and
`status=declined` therefore return their rows in full, unfiltered by
size. `include_retired=true` exempts only the retired rows it adds —
the live themes alongside them stay filtered.
`/features` differs — a feature is sized by its own insight count, so its
floor applies whatever its status.
parameters:
- name: show_all
in: query
schema: { type: boolean, default: false }
description: "Include themes below the team's adaptive size floor. Default false."
- name: q
in: query
schema: { type: string, maxLength: 500 }
description: Search text. Required when search_mode is semantic.
- name: search_mode
in: query
schema:
type: string
enum: [lexical, semantic]
default: lexical
description: Lexical substring matching, or semantic matching against theme embeddings.
- name: min_similarity
in: query
schema:
type: number
format: float
minimum: 0.3
maximum: 1
default: 0.5
description: Minimum cosine similarity for semantic search. Valid only when search_mode is semantic.
- name: allow_fallback
in: query
schema: { type: boolean, default: false }
description: Permit lexical fallback if semantic search is unavailable. Valid only when search_mode is semantic.
- name: include_retired
in: query
schema: { type: boolean, default: false }
description: Add valid merged themes, each with its final replacement_theme_id. Other filters still apply.
- name: status
in: query
schema: { type: string, enum: [active, declined, shipped, parked] }
- name: sort
in: query
schema: { type: string, enum: [ric_score, insight_count, unique_customer_count, created_at], default: ric_score }
- $ref: '#/components/parameters/ProductId'
- $ref: '#/components/parameters/ProductFeatureId'
- $ref: '#/components/parameters/ProductAreaId'
- $ref: '#/components/parameters/FeatureAreaId'
- $ref: '#/components/parameters/DateFrom'
- $ref: '#/components/parameters/DateTo'
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: A page of themes
content:
application/json:
schema:
type: object
required: [data, pagination, search_mode, min_similarity]
properties:
data:
type: array
items: { $ref: '#/components/schemas/Theme' }
pagination: { $ref: '#/components/schemas/Pagination' }
search_mode:
type: string
enum: [lexical, semantic]
description: Search mode actually applied. Lexical indicates lexical/default search or an explicitly allowed fallback.
min_similarity:
type: number
format: float
nullable: true
description: Applied semantic threshold, or null when search_mode is lexical.
example:
data:
- id: "a1b2c3d4-e5f6-4a7b-8c9d-000000000001"
title: "Payment interruption recovery"
description: "Customers need clearer recovery paths after failed transactions."
ric_score: 7.8
status: "active"
pagination: { total: 1, limit: 50, offset: 0 }
search_mode: "semantic"
min_similarity: 0.5
'400': { $ref: '#/components/responses/ValidationError' }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { $ref: '#/components/responses/RateLimited' }
'503': { $ref: '#/components/responses/ThemesUnavailable' }
/themes/{id}:
get:
operationId: getTheme
tags: [Themes]
summary: Get a theme
x-mint:
metadata:
description: "Get one product theme with its severity breakdown, affected customers, supporting insights, buildable features, and merge destination."
description: |
Theme detail with its evidence: severity breakdown, affected customers, top
supporting insights, and its features. If this theme was merged into another,
the response is `410 Gone` with the final surviving `replacement_theme_id`.
parameters:
- $ref: '#/components/parameters/PathId'
responses:
'200':
description: Theme detail
content:
application/json:
schema: { $ref: '#/components/schemas/ThemeDetail' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'410': { $ref: '#/components/responses/ThemeRetired' }
'503': { $ref: '#/components/responses/AuthenticationUnavailable' }
/features:
get:
operationId: listFeatures
tags: [Features]
summary: List / search features
x-mint:
metadata:
description: "List or search buildable features by theme, product, area, status, or text, with scoped evidence counts and deterministic sorting."
description: |
Buildable features: the shippable children of a theme. Filter by `theme_id`,
`product_id`, `feature_area_id`, `product_area_id`, `product_feature_id`,
status, or search text.
Product fields are null when a feature has not been assigned to a product.
`feature_area_id` is a subject area from `/areas`, NOT a feature ID from this
endpoint. The two are different kinds of ID: a feature ID identifies one
buildable feature and is what `/features/{id}` takes, while a feature-area ID
identifies the area an insight is filed under. `feature_area_id` therefore
selects features by the area of their supporting insights.
With an AREA filter — `feature_area_id`, `product_area_id`, or both —
`insight_count` and `unique_customer_count` are RECOMPUTED from the distinct
insights in that scope and the distinct customers behind them; features with
no matching evidence are omitted, and `pagination.total` counts what is
returned. `sort=insight_count` and `sort=unique_customer_count` then rank on
those scoped values. `sort=ric_score` is unchanged by the filter: it stays the
feature's stored OVERALL score across every area, so a feature can outrank
another that has more evidence in the selected scope. Every sort resolves ties
on the immutable feature ID, so paging is deterministic. With NEITHER area
filter the counts are the feature's stored all-area totals. Every filter on
this endpoint combines with AND.
The default response is CURATED, not a census: each team has an adaptive
size floor, computed nightly from that team's own distribution, and very
small items below it are excluded — the same set the ClosedLoop AI app
shows. `pagination.total` counts the curated set. Set `show_all=true` for
every row regardless of size. Teams with too little data to compute a
floor are never filtered.
Unlike `/themes`, this floor applies at every status: a feature is sized
by its own insight count, which a finished feature still has. That size is the
feature's OVERALL insight count, so the floor is unaffected by the area
filters and `show_all=true` does not widen the selected area. The floor and
the published count therefore read different numbers: a returned feature can
carry a scoped `insight_count` below the floor, because the floor judged its
overall size, and a feature whose evidence sits entirely in the selected area
is still hidden when its overall count is below the floor.
parameters:
- name: show_all
in: query
schema: { type: boolean, default: false }
description: "Include features below the team's adaptive size floor. Default false."
- $ref: '#/components/parameters/Query'
- name: search_mode
in: query
schema:
type: string
enum: [lexical, semantic]
default: lexical
description: |
Lexical substring matching against the feature's own title and
description, or semantic matching against feature embeddings.
Semantic search returns rows best-match-first and therefore OVERRIDES
`sort`. Relevance order and size order are mutually exclusive: a caller
relying on `sort=insight_count` for scope-aware ordering loses it when
semantic is enabled.
Semantic requests share ONE rate budget with `/themes`; they are not
separately allowanced.
- name: min_similarity
in: query
schema:
type: number
format: float
minimum: 0.3
maximum: 1
default: 0.5
description: |
Minimum cosine similarity for semantic search. Valid only when
search_mode is semantic.
- name: allow_fallback
in: query
schema: { type: boolean, default: false }
description: |
Permit falling back to lexical when an embedding cannot be generated.
Default false, so such a request fails with 503 rather than silently
returning a different result set. The response always reports the mode
that actually ran.
- name: theme_id
in: query
schema: { type: string, format: uuid }
description: Return only the features under this theme.
- $ref: '#/components/parameters/ProductId'
- $ref: '#/components/parameters/FeatureAreaId'
- name: product_area_id
in: query
schema: { type: string, format: uuid }
description: |
Return only features with supporting insights filed anywhere under this
parent product area, with `insight_count` and `unique_customer_count`
recomputed over exactly that evidence. This is `feature_area_id` one
level up: a product area is the parent of many feature areas, so it is
the coarser cut of the same question. Combines with every other filter
using AND, so pairing it with `feature_area_id` narrows to that one
child area — and yields nothing when that area sits under a different
product area.
ARCHIVED child areas are excluded, which `feature_area_id` does not do:
naming an archived area directly still counts its evidence, while its
parent product area does not. A product area whose children are all
archived therefore returns an empty list. `/areas` with
`include_archived=true` sums archived children into a product area's
`insight_count`, so that total can exceed what this filter returns.
- name: product_feature_id
in: query
schema: { type: string, format: uuid }
description: |
Return only the feature with this ID. On THIS endpoint the parameter
names the listed resource itself, so it selects that one feature;
on `/insights` and `/themes` the same parameter instead means
"linked to this feature". Combines with every other filter using
AND, so pairing it with `feature_area_id` answers "does this feature
have evidence in that area, and how much" in one call. Use
`/features/{id}` to fetch one feature with its evidence, severity
breakdown and affected customers.
- name: status
in: query
schema: { type: string, enum: [active, declined, shipped, parked] }
- name: sort
in: query
schema: { type: string, enum: [ric_score, insight_count, unique_customer_count, created_at], default: ric_score }
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: A page of features
co
# --- truncated at 32 KB (75 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/closedloop/refs/heads/main/openapi/closedloop-public-api-openapi.yaml