curl --request GET \
--url https://api.closedloop.sh/v1/features \
--header 'X-API-Key: <api-key>'import requests
url = "https://api.closedloop.sh/v1/features"
headers = {"X-API-Key": "<api-key>"}
response = requests.get(url, headers=headers)
print(response.text)const options = {method: 'GET', headers: {'X-API-Key': '<api-key>'}};
fetch('https://api.closedloop.sh/v1/features', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.closedloop.sh/v1/features",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.closedloop.sh/v1/features"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("X-API-Key", "<api-key>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.get("https://api.closedloop.sh/v1/features")
.header("X-API-Key", "<api-key>")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.closedloop.sh/v1/features")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["X-API-Key"] = '<api-key>'
response = http.request(request)
puts response.read_body{
"data": [
{
"id": "b2c3d4e5-f6a7-4b8c-9d0e-000000000002",
"theme_id": "a1b2c3d4-e5f6-4a7b-8c9d-000000000001",
"title": "Dark mode toggle in settings",
"ric_score": 6.9,
"status": "active",
"description": "A user-controllable dark / light toggle in the settings panel.",
"insight_count": 12,
"unique_customer_count": 9,
"product_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"product_name": "Core Platform",
"created_at": "2026-04-05T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
}
],
"pagination": {
"total": 508,
"limit": 50,
"offset": 0
},
"search_mode": "lexical",
"min_similarity": 123
}List / search features
List or search buildable features by theme, product, area, status, or text, with scoped evidence counts and deterministic sorting.
curl --request GET \
--url https://api.closedloop.sh/v1/features \
--header 'X-API-Key: <api-key>'import requests
url = "https://api.closedloop.sh/v1/features"
headers = {"X-API-Key": "<api-key>"}
response = requests.get(url, headers=headers)
print(response.text)const options = {method: 'GET', headers: {'X-API-Key': '<api-key>'}};
fetch('https://api.closedloop.sh/v1/features', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.closedloop.sh/v1/features",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.closedloop.sh/v1/features"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("X-API-Key", "<api-key>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.get("https://api.closedloop.sh/v1/features")
.header("X-API-Key", "<api-key>")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.closedloop.sh/v1/features")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["X-API-Key"] = '<api-key>'
response = http.request(request)
puts response.read_body{
"data": [
{
"id": "b2c3d4e5-f6a7-4b8c-9d0e-000000000002",
"theme_id": "a1b2c3d4-e5f6-4a7b-8c9d-000000000001",
"title": "Dark mode toggle in settings",
"ric_score": 6.9,
"status": "active",
"description": "A user-controllable dark / light toggle in the settings panel.",
"insight_count": 12,
"unique_customer_count": 9,
"product_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"product_name": "Core Platform",
"created_at": "2026-04-05T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
}
],
"pagination": {
"total": 508,
"limit": 50,
"offset": 0
},
"search_mode": "lexical",
"min_similarity": 123
}Authorizations
Team-scoped API key created in the app (Settings → API Keys).
Sent as X-API-Key: <key> on every request. The header name apikey
is also accepted as an alias.
Query Parameters
Include features below the team's adaptive size floor. Default false.
Free-text search over titles and content.
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.
lexical, semantic Minimum cosine similarity for semantic search. Valid only when search_mode is semantic.
0.3 <= x <= 1Permit 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.
Return only the features under this theme.
Filter by one product ID from /products.
Filter by one child feature-area ID from /areas. A feature area is the subject
area an insight is filed under; it is NOT a buildable feature ID from
/features, and the two are never interchangeable.
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.
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.
active, declined, shipped, parked ric_score, insight_count, unique_customer_count, created_at Page size (max 200).
1 <= x <= 200Number of records to skip.
x >= 0Response
A page of features
Show child attributes
Show child attributes
Show child attributes
Show child attributes
Search mode actually applied. Lexical indicates lexical/default search or an explicitly allowed fallback.
lexical, semantic Applied semantic threshold, or null when the applied mode was lexical.