diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 8032c17..ed21d28 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.12.0"
+ ".": "0.13.0"
}
diff --git a/.stats.yml b/.stats.yml
index 71cbc3b..39cbed1 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 29
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/formbricks/hub-876b3cc61d59eee7f50b21239e174146afb6176473af1f7c6ac6ce8900e40b9c.yml
-openapi_spec_hash: f0acdda9bda9e4733d298cf9082b58e7
-config_hash: 0b48961f5f0b4dca3a9a4342cd42bd51
+configured_endpoints: 30
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/formbricks/hub-0822f44f62ac4c826260c8072710c5c7f490653c2c9c9666e349d34cc9c99e90.yml
+openapi_spec_hash: 4eeeec7b5b6a4d484eec92d1718dc640
+config_hash: a4efd21029f0fb9bf9e9abd69b06ac84
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b628da7..73a9d84 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## 0.13.0 (2026-08-25)
+
+Full Changelog: [v0.12.0...v0.13.0](https://github.com/formbricks/hub-typescript/compare/v0.12.0...v0.13.0)
+
+### Features
+
+* add a records-scoped tenant purge endpoint (ENG-2129) ([a6b9afa](https://github.com/formbricks/hub-typescript/commit/a6b9afa33356b7f582c11c0f56d6d242153ee0da))
+* report why an enrichment is off and what failed, durably (ENG-2375) ([b6c363b](https://github.com/formbricks/hub-typescript/commit/b6c363bf4bc67068b1d7f32892fde0da179750c9))
+
+
+### Bug Fixes
+
+* **taxonomy:** harden run persistence ([721e58b](https://github.com/formbricks/hub-typescript/commit/721e58b7c66a7800040b6b31e277f5f2191f766b))
+
## 0.12.0 (2026-08-12)
Full Changelog: [v0.11.0...v0.12.0](https://github.com/formbricks/hub-typescript/compare/v0.11.0...v0.12.0)
diff --git a/api.md b/api.md
index ee5731a..ca8079d 100644
--- a/api.md
+++ b/api.md
@@ -61,10 +61,12 @@ Methods:
Types:
- TenantDeleteDataResponse
+- TenantPurgeFeedbackRecordsResponse
Methods:
- client.tenants.deleteData(tenantID) -> TenantDeleteDataResponse
+- client.tenants.purgeFeedbackRecords(tenantID) -> TenantPurgeFeedbackRecordsResponse
## Settings
diff --git a/package.json b/package.json
index fbccf89..f6b6352 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@formbricks/hub",
- "version": "0.12.0",
+ "version": "0.13.0",
"description": "The official TypeScript library for the Formbricks Hub API",
"author": "Formbricks Hub ",
"types": "dist/index.d.ts",
diff --git a/packages/mcp-server/manifest.json b/packages/mcp-server/manifest.json
index ca84895..7cd9692 100644
--- a/packages/mcp-server/manifest.json
+++ b/packages/mcp-server/manifest.json
@@ -1,7 +1,7 @@
{
"dxt_version": "0.2",
"name": "@formbricks/hub-mcp",
- "version": "0.12.0",
+ "version": "0.13.0",
"description": "The official MCP Server for the Formbricks Hub API",
"author": {
"name": "Formbricks Hub",
diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json
index d55f4e1..dc14083 100644
--- a/packages/mcp-server/package.json
+++ b/packages/mcp-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@formbricks/hub-mcp",
- "version": "0.12.0",
+ "version": "0.13.0",
"description": "The official MCP Server for the Formbricks Hub API",
"author": "Formbricks Hub ",
"types": "dist/index.d.ts",
diff --git a/packages/mcp-server/src/code-tool-worker.ts b/packages/mcp-server/src/code-tool-worker.ts
index dcfd713..dab8829 100644
--- a/packages/mcp-server/src/code-tool-worker.ts
+++ b/packages/mcp-server/src/code-tool-worker.ts
@@ -124,6 +124,7 @@ const fuse = new Fuse(
'client.webhooks.retrieve',
'client.webhooks.update',
'client.tenants.deleteData',
+ 'client.tenants.purgeFeedbackRecords',
'client.tenants.settings.retrieve',
'client.tenants.settings.update',
'client.taxonomy.listFields',
diff --git a/packages/mcp-server/src/local-docs-search.ts b/packages/mcp-server/src/local-docs-search.ts
index 39df01d..bdfcf01 100644
--- a/packages/mcp-server/src/local-docs-search.ts
+++ b/packages/mcp-server/src/local-docs-search.ts
@@ -539,9 +539,9 @@ const EMBEDDED_METHODS: MethodEntry[] = [
qualified: 'client.tenants.deleteData',
params: ['tenant_id: string;'],
response:
- '{ deleted_embeddings: number; deleted_feedback_records: number; deleted_taxonomy_active_runs: number; deleted_taxonomy_cluster_memberships: number; deleted_taxonomy_clusters: number; deleted_taxonomy_node_events: number; deleted_taxonomy_nodes: number; deleted_taxonomy_runs: number; deleted_webhooks: number; message: string; tenant_id: string; }',
+ '{ deleted_embeddings: number; deleted_feedback_records: number; deleted_taxonomy_active_runs: number; deleted_taxonomy_cluster_memberships: number; deleted_taxonomy_clusters: number; deleted_taxonomy_node_events: number; deleted_taxonomy_nodes: number; deleted_taxonomy_run_input_records: number; deleted_taxonomy_runs: number; deleted_webhooks: number; message: string; tenant_id: string; }',
markdown:
- "## delete_data\n\n`client.tenants.deleteData(tenant_id: string): { deleted_embeddings: number; deleted_feedback_records: number; deleted_taxonomy_active_runs: number; deleted_taxonomy_cluster_memberships: number; deleted_taxonomy_clusters: number; deleted_taxonomy_node_events: number; deleted_taxonomy_nodes: number; deleted_taxonomy_runs: number; deleted_webhooks: number; message: string; tenant_id: string; }`\n\n**delete** `/v1/tenants/{tenant_id}/data`\n\nPermanently deletes Hub-owned data for the specified tenant_id. This endpoint is intended for\ntenant/account offboarding after the tenant has been deprovisioned and upstream writes for that\ntenant have stopped. This includes feedback records, derived embeddings, taxonomy data, and\nwebhooks for the tenant. This operation is synchronous and idempotent; repeated calls return zero\ncounts after the tenant data has already been deleted.\n\nThe purge is serialized against tenant-owned writes: while it runs, Hub-owned writes for the same\ntenant_id are rejected with HTTP 409 (code `tenant_write_conflict`); writes for other tenants are\nunaffected. If tenant-owned writes are in flight when the purge starts, the purge waits up to a\nconfigured lock timeout for them to drain and then returns a retryable 409.\n\nNo webhook events are published as part of this purge operation, and writes rejected during the\npurge publish no events. Webhook deliveries already in flight when the purge commits complete\nnormally, and previously enqueued delivery jobs no-op after the purge (their queued payloads are\nremoved by the job queue's retention pruning rather than by this endpoint).\n\n\n### Parameters\n\n- `tenant_id: string`\n\n### Returns\n\n- `{ deleted_embeddings: number; deleted_feedback_records: number; deleted_taxonomy_active_runs: number; deleted_taxonomy_cluster_memberships: number; deleted_taxonomy_clusters: number; deleted_taxonomy_node_events: number; deleted_taxonomy_nodes: number; deleted_taxonomy_runs: number; deleted_webhooks: number; message: string; tenant_id: string; }`\n\n - `deleted_embeddings: number`\n - `deleted_feedback_records: number`\n - `deleted_taxonomy_active_runs: number`\n - `deleted_taxonomy_cluster_memberships: number`\n - `deleted_taxonomy_clusters: number`\n - `deleted_taxonomy_node_events: number`\n - `deleted_taxonomy_nodes: number`\n - `deleted_taxonomy_runs: number`\n - `deleted_webhooks: number`\n - `message: string`\n - `tenant_id: string`\n\n### Example\n\n```typescript\nimport FormbricksHub from '@formbricks/hub';\n\nconst client = new FormbricksHub();\n\nconst response = await client.tenants.deleteData('org-123');\n\nconsole.log(response);\n```",
+ "## delete_data\n\n`client.tenants.deleteData(tenant_id: string): { deleted_embeddings: number; deleted_feedback_records: number; deleted_taxonomy_active_runs: number; deleted_taxonomy_cluster_memberships: number; deleted_taxonomy_clusters: number; deleted_taxonomy_node_events: number; deleted_taxonomy_nodes: number; deleted_taxonomy_run_input_records: number; deleted_taxonomy_runs: number; deleted_webhooks: number; message: string; tenant_id: string; }`\n\n**delete** `/v1/tenants/{tenant_id}/data`\n\nPermanently deletes Hub-owned data for the specified tenant_id. This endpoint is intended for\ntenant/account offboarding after the tenant has been deprovisioned and upstream writes for that\ntenant have stopped. This includes feedback records, derived embeddings, taxonomy data, and\nwebhooks for the tenant. This operation is synchronous and idempotent; repeated calls return zero\ncounts after the tenant data has already been deleted.\n\nThe purge is serialized against tenant-owned writes: while it runs, Hub-owned writes for the same\ntenant_id are rejected with HTTP 409 (code `tenant_write_conflict`); writes for other tenants are\nunaffected. If tenant-owned writes are in flight when the purge starts, the purge waits up to a\nconfigured lock timeout for them to drain and then returns a retryable 409.\n\nNo webhook events are published as part of this purge operation, and writes rejected during the\npurge publish no events. Webhook deliveries already in flight when the purge commits complete\nnormally, and previously enqueued delivery jobs no-op after the purge (their queued payloads are\nremoved by the job queue's retention pruning rather than by this endpoint).\n\n\n### Parameters\n\n- `tenant_id: string`\n\n### Returns\n\n- `{ deleted_embeddings: number; deleted_feedback_records: number; deleted_taxonomy_active_runs: number; deleted_taxonomy_cluster_memberships: number; deleted_taxonomy_clusters: number; deleted_taxonomy_node_events: number; deleted_taxonomy_nodes: number; deleted_taxonomy_run_input_records: number; deleted_taxonomy_runs: number; deleted_webhooks: number; message: string; tenant_id: string; }`\n\n - `deleted_embeddings: number`\n - `deleted_feedback_records: number`\n - `deleted_taxonomy_active_runs: number`\n - `deleted_taxonomy_cluster_memberships: number`\n - `deleted_taxonomy_clusters: number`\n - `deleted_taxonomy_node_events: number`\n - `deleted_taxonomy_nodes: number`\n - `deleted_taxonomy_run_input_records: number`\n - `deleted_taxonomy_runs: number`\n - `deleted_webhooks: number`\n - `message: string`\n - `tenant_id: string`\n\n### Example\n\n```typescript\nimport FormbricksHub from '@formbricks/hub';\n\nconst client = new FormbricksHub();\n\nconst response = await client.tenants.deleteData('org-123');\n\nconsole.log(response);\n```",
perLanguage: {
typescript: {
method: 'client.tenants.deleteData',
@@ -554,6 +554,31 @@ const EMBEDDED_METHODS: MethodEntry[] = [
},
},
},
+ {
+ name: 'purge_feedback_records',
+ endpoint: '/v1/tenants/{tenant_id}/feedback-records',
+ httpMethod: 'delete',
+ summary: 'Purge all feedback records for a tenant',
+ description:
+ 'Permanently deletes every feedback record for the specified tenant_id, everything derived from\nthose records — embeddings and the enrichment stored on each record (sentiment, emotions,\ntranslations) — and the taxonomy built on them: runs, clusters, nodes, cluster memberships,\nactive-run pointers and node events.\n\nThis is intended for emptying a dataset that stays in use, so it removes the tenant\'s DATA but\nnever its CONFIGURATION: webhooks and tenant settings are left untouched. That is the difference\nfrom `DELETE /v1/tenants/{tenant_id}/data`, which additionally deletes both and is meant for\noffboarding a deprovisioned tenant.\n\nThe taxonomy is removed rather than preserved because it describes records that no longer exist:\nits per-node counts are derived from memberships and would all read zero, while a run\'s own stored\ncounters (`record_count`, `cluster_count`, a cluster\'s `size`) are historical and would keep\nadvertising the old numbers. A new taxonomy can be generated once the dataset has enough feedback\nagain; manual node renames and removals are per-run and do not survive a regeneration in any case.\n\nThe tenant is a required path segment, so it cannot be omitted the way a filter on a collection\ndelete could be — dropping it routes elsewhere rather than widening the operation to every tenant.\n\nAsynchronous: the request schedules the purge and returns 202 immediately, because the deletion is\nunbounded and can outlive a request. There is therefore no deleted count in the response. Poll\n`GET /v1/feedback-records/count?tenant_id=...` to observe progress.\n\nThe purge removes only the records that existed when it started — it takes a high-water mark up\nfront — so feedback ingested while it runs is never deleted. For a dataset that is no longer\nreceiving feedback the count reaching zero means the purge is complete; for one still ingesting,\na nonzero count is those newer records. Note this means the count alone cannot distinguish\n"finished" from "failed" on an active dataset.\n\nIdempotent and safe to repeat. Requesting a purge while one is already running for the same tenant\njoins the running purge rather than queueing a second one, and still returns 202. A purge requested\nafter an earlier one finished starts a new run.\n\nRecords are deleted in committed batches, so a purge interrupted by a restart or a timeout keeps\nthe progress it made and resumes on retry; the taxonomy is removed in a final step once the records\nare gone. While each step runs, the tenant\'s write lock is held exclusively and Hub-owned writes\nfor that tenant are rejected with HTTP 409 (code `tenant_write_conflict`); the lock is released\nbetween steps, and writes for other tenants are never affected. This does not apply to the request below — scheduling a purge takes no lock, so\nthis endpoint does not return 409. A batch that cannot acquire the lock is retried by the job\nqueue without any caller action, up to a bounded number of attempts.\n\nNo webhook events are published for a purge, and no webhooks are deleted. Enrichment jobs already\nqueued for purged records no-op when they run, since the record they reference is gone.\n',
+ stainlessPath: '(resource) tenants > (method) purge_feedback_records',
+ qualified: 'client.tenants.purgeFeedbackRecords',
+ params: ['tenant_id: string;'],
+ response: "{ status: 'accepted'; tenant_id: string; message?: string; }",
+ markdown:
+ "## purge_feedback_records\n\n`client.tenants.purgeFeedbackRecords(tenant_id: string): { status: 'accepted'; tenant_id: string; message?: string; }`\n\n**delete** `/v1/tenants/{tenant_id}/feedback-records`\n\nPermanently deletes every feedback record for the specified tenant_id, everything derived from\nthose records — embeddings and the enrichment stored on each record (sentiment, emotions,\ntranslations) — and the taxonomy built on them: runs, clusters, nodes, cluster memberships,\nactive-run pointers and node events.\n\nThis is intended for emptying a dataset that stays in use, so it removes the tenant's DATA but\nnever its CONFIGURATION: webhooks and tenant settings are left untouched. That is the difference\nfrom `DELETE /v1/tenants/{tenant_id}/data`, which additionally deletes both and is meant for\noffboarding a deprovisioned tenant.\n\nThe taxonomy is removed rather than preserved because it describes records that no longer exist:\nits per-node counts are derived from memberships and would all read zero, while a run's own stored\ncounters (`record_count`, `cluster_count`, a cluster's `size`) are historical and would keep\nadvertising the old numbers. A new taxonomy can be generated once the dataset has enough feedback\nagain; manual node renames and removals are per-run and do not survive a regeneration in any case.\n\nThe tenant is a required path segment, so it cannot be omitted the way a filter on a collection\ndelete could be — dropping it routes elsewhere rather than widening the operation to every tenant.\n\nAsynchronous: the request schedules the purge and returns 202 immediately, because the deletion is\nunbounded and can outlive a request. There is therefore no deleted count in the response. Poll\n`GET /v1/feedback-records/count?tenant_id=...` to observe progress.\n\nThe purge removes only the records that existed when it started — it takes a high-water mark up\nfront — so feedback ingested while it runs is never deleted. For a dataset that is no longer\nreceiving feedback the count reaching zero means the purge is complete; for one still ingesting,\na nonzero count is those newer records. Note this means the count alone cannot distinguish\n\"finished\" from \"failed\" on an active dataset.\n\nIdempotent and safe to repeat. Requesting a purge while one is already running for the same tenant\njoins the running purge rather than queueing a second one, and still returns 202. A purge requested\nafter an earlier one finished starts a new run.\n\nRecords are deleted in committed batches, so a purge interrupted by a restart or a timeout keeps\nthe progress it made and resumes on retry; the taxonomy is removed in a final step once the records\nare gone. While each step runs, the tenant's write lock is held exclusively and Hub-owned writes\nfor that tenant are rejected with HTTP 409 (code `tenant_write_conflict`); the lock is released\nbetween steps, and writes for other tenants are never affected. This does not apply to the request below — scheduling a purge takes no lock, so\nthis endpoint does not return 409. A batch that cannot acquire the lock is retried by the job\nqueue without any caller action, up to a bounded number of attempts.\n\nNo webhook events are published for a purge, and no webhooks are deleted. Enrichment jobs already\nqueued for purged records no-op when they run, since the record they reference is gone.\n\n\n### Parameters\n\n- `tenant_id: string`\n\n### Returns\n\n- `{ status: 'accepted'; tenant_id: string; message?: string; }`\n\n - `status: 'accepted'`\n - `tenant_id: string`\n - `message?: string`\n\n### Example\n\n```typescript\nimport FormbricksHub from '@formbricks/hub';\n\nconst client = new FormbricksHub();\n\nconst response = await client.tenants.purgeFeedbackRecords('org-123');\n\nconsole.log(response);\n```",
+ perLanguage: {
+ typescript: {
+ method: 'client.tenants.purgeFeedbackRecords',
+ example:
+ "import FormbricksHub from '@formbricks/hub';\n\nconst client = new FormbricksHub({\n apiKey: process.env['HUB_API_KEY'], // This is the default and can be omitted\n});\n\nconst response = await client.tenants.purgeFeedbackRecords('org-123');\n\nconsole.log(response.tenant_id);",
+ },
+ http: {
+ example:
+ 'curl http://localhost:8080/v1/tenants/$TENANT_ID/feedback-records \\\n -X DELETE \\\n -H "Authorization: Bearer $HUB_API_KEY"',
+ },
+ },
+ },
{
name: 'retrieve',
endpoint: '/v1/tenants/{tenant_id}/settings',
@@ -897,14 +922,14 @@ const EMBEDDED_METHODS: MethodEntry[] = [
httpMethod: 'get',
summary: 'Get tenant enrichment status',
description:
- 'Returns a tenant\'s enrichment progress across the record-level enrichments\n(translation, sentiment, emotions). For each, `enabled` reports whether the enrichment\nis active for the tenant (deployment-configured and switched on / with a resolvable\ntarget language), and `eligible`/`done` are directory-level counts of feedback records\nthat qualify and that have been enriched — the UI derives "in progress" as\n`eligible - done`. When an enrichment is not enabled its counts are zero. The response\ncontains counts only (no record identifiers or content).\n',
+ 'Returns a tenant\'s enrichment progress across the record-level enrichments\n(translation, sentiment, emotions). For each, `enabled` reports whether the enrichment\nis active for the tenant (deployment-configured and switched on / with a resolvable\ntarget language), and `eligible`/`done` are directory-level counts of feedback records\nthat qualify and that have been enriched — the UI derives "in progress" as\n`eligible - done`, of which `failed` is retryable and `failed_terminal` never will be.\nWhen an enrichment is not enabled its counts are zero and\n`disabled_reason` names the gate that closed it, so the UI can explain *why* rather than\nsilently hiding the enrichment. `as_of` is when the counts were computed, which lets a\npolling client derive throughput and an ETA without the Hub computing either. The response\ncontains counts only (no record identifiers or content).\n',
stainlessPath: '(resource) enrichment_status > (method) retrieve',
qualified: 'client.enrichmentStatus.retrieve',
params: ['tenant_id: string;'],
response:
- '{ emotions: { done: number; eligible: number; enabled: boolean; }; sentiment: { done: number; eligible: number; enabled: boolean; }; tenant_id: string; translation: { done: number; eligible: number; enabled: boolean; }; }',
+ "{ as_of: string; emotions: { done: number; eligible: number; enabled: boolean; failed: number; failed_terminal: number; disabled_reason?: 'not_configured' | 'switched_off' | 'no_target_language'; }; sentiment: { done: number; eligible: number; enabled: boolean; failed: number; failed_terminal: number; disabled_reason?: 'not_configured' | 'switched_off' | 'no_target_language'; }; tenant_id: string; translation: { done: number; eligible: number; enabled: boolean; failed: number; failed_terminal: number; disabled_reason?: 'not_configured' | 'switched_off' | 'no_target_language'; }; }",
markdown:
- "## retrieve\n\n`client.enrichmentStatus.retrieve(tenant_id: string): { emotions: type_status; sentiment: type_status; tenant_id: string; translation: type_status; }`\n\n**get** `/v1/enrichment-status`\n\nReturns a tenant's enrichment progress across the record-level enrichments\n(translation, sentiment, emotions). For each, `enabled` reports whether the enrichment\nis active for the tenant (deployment-configured and switched on / with a resolvable\ntarget language), and `eligible`/`done` are directory-level counts of feedback records\nthat qualify and that have been enriched — the UI derives \"in progress\" as\n`eligible - done`. When an enrichment is not enabled its counts are zero. The response\ncontains counts only (no record identifiers or content).\n\n\n### Parameters\n\n- `tenant_id: string`\n Tenant whose enrichment status should be returned.\n\n### Returns\n\n- `{ emotions: { done: number; eligible: number; enabled: boolean; }; sentiment: { done: number; eligible: number; enabled: boolean; }; tenant_id: string; translation: { done: number; eligible: number; enabled: boolean; }; }`\n A tenant's enrichment progress across the record-level enrichments. Counts are directory-level totals.\n\n - `emotions: { done: number; eligible: number; enabled: boolean; }`\n - `sentiment: { done: number; eligible: number; enabled: boolean; }`\n - `tenant_id: string`\n - `translation: { done: number; eligible: number; enabled: boolean; }`\n\n### Example\n\n```typescript\nimport FormbricksHub from '@formbricks/hub';\n\nconst client = new FormbricksHub();\n\nconst enrichmentStatus = await client.enrichmentStatus.retrieve({ tenant_id: 'org-123' });\n\nconsole.log(enrichmentStatus);\n```",
+ "## retrieve\n\n`client.enrichmentStatus.retrieve(tenant_id: string): { as_of: string; emotions: type_status; sentiment: type_status; tenant_id: string; translation: type_status; }`\n\n**get** `/v1/enrichment-status`\n\nReturns a tenant's enrichment progress across the record-level enrichments\n(translation, sentiment, emotions). For each, `enabled` reports whether the enrichment\nis active for the tenant (deployment-configured and switched on / with a resolvable\ntarget language), and `eligible`/`done` are directory-level counts of feedback records\nthat qualify and that have been enriched — the UI derives \"in progress\" as\n`eligible - done`, of which `failed` is retryable and `failed_terminal` never will be.\nWhen an enrichment is not enabled its counts are zero and\n`disabled_reason` names the gate that closed it, so the UI can explain *why* rather than\nsilently hiding the enrichment. `as_of` is when the counts were computed, which lets a\npolling client derive throughput and an ETA without the Hub computing either. The response\ncontains counts only (no record identifiers or content).\n\n\n### Parameters\n\n- `tenant_id: string`\n Tenant whose enrichment status should be returned.\n\n### Returns\n\n- `{ as_of: string; emotions: { done: number; eligible: number; enabled: boolean; failed: number; failed_terminal: number; disabled_reason?: 'not_configured' | 'switched_off' | 'no_target_language'; }; sentiment: { done: number; eligible: number; enabled: boolean; failed: number; failed_terminal: number; disabled_reason?: 'not_configured' | 'switched_off' | 'no_target_language'; }; tenant_id: string; translation: { done: number; eligible: number; enabled: boolean; failed: number; failed_terminal: number; disabled_reason?: 'not_configured' | 'switched_off' | 'no_target_language'; }; }`\n A tenant's enrichment progress across the record-level enrichments. Counts are directory-level totals.\n\n - `as_of: string`\n - `emotions: { done: number; eligible: number; enabled: boolean; failed: number; failed_terminal: number; disabled_reason?: 'not_configured' | 'switched_off' | 'no_target_language'; }`\n - `sentiment: { done: number; eligible: number; enabled: boolean; failed: number; failed_terminal: number; disabled_reason?: 'not_configured' | 'switched_off' | 'no_target_language'; }`\n - `tenant_id: string`\n - `translation: { done: number; eligible: number; enabled: boolean; failed: number; failed_terminal: number; disabled_reason?: 'not_configured' | 'switched_off' | 'no_target_language'; }`\n\n### Example\n\n```typescript\nimport FormbricksHub from '@formbricks/hub';\n\nconst client = new FormbricksHub();\n\nconst enrichmentStatus = await client.enrichmentStatus.retrieve({ tenant_id: 'org-123' });\n\nconsole.log(enrichmentStatus);\n```",
perLanguage: {
typescript: {
method: 'client.enrichmentStatus.retrieve',
diff --git a/packages/mcp-server/src/methods.ts b/packages/mcp-server/src/methods.ts
index aa24431..3e955c3 100644
--- a/packages/mcp-server/src/methods.ts
+++ b/packages/mcp-server/src/methods.ts
@@ -106,6 +106,12 @@ export const sdkMethods: SdkMethod[] = [
httpMethod: 'delete',
httpPath: '/v1/tenants/{tenant_id}/data',
},
+ {
+ clientCallName: 'client.tenants.purgeFeedbackRecords',
+ fullyQualifiedName: 'tenants.purgeFeedbackRecords',
+ httpMethod: 'delete',
+ httpPath: '/v1/tenants/{tenant_id}/feedback-records',
+ },
{
clientCallName: 'client.tenants.settings.retrieve',
fullyQualifiedName: 'tenants.settings.retrieve',
diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts
index 89b2343..786b706 100644
--- a/packages/mcp-server/src/server.ts
+++ b/packages/mcp-server/src/server.ts
@@ -28,7 +28,7 @@ export const newMcpServer = async ({
new McpServer(
{
name: 'formbricks_hub_api',
- version: '0.12.0',
+ version: '0.13.0',
},
{
instructions: await getInstructions({ stainlessApiKey, customInstructionsPath }),
diff --git a/src/client.ts b/src/client.ts
index 172e98a..dca4ff2 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -55,7 +55,11 @@ import {
TaxonomyListFieldsParams,
TaxonomyListFieldsResponse,
} from './resources/taxonomy/taxonomy';
-import { TenantDeleteDataResponse, Tenants } from './resources/tenants/tenants';
+import {
+ TenantDeleteDataResponse,
+ TenantPurgeFeedbackRecordsResponse,
+ Tenants,
+} from './resources/tenants/tenants';
import { type Fetch } from './internal/builtin-types';
import { HeadersLike, NullableHeaders, buildHeaders } from './internal/headers';
import { FinalRequestOptions, RequestOptions } from './internal/request-options';
@@ -837,7 +841,11 @@ export declare namespace FormbricksHub {
type WebhookListParams as WebhookListParams,
};
- export { Tenants as Tenants, type TenantDeleteDataResponse as TenantDeleteDataResponse };
+ export {
+ Tenants as Tenants,
+ type TenantDeleteDataResponse as TenantDeleteDataResponse,
+ type TenantPurgeFeedbackRecordsResponse as TenantPurgeFeedbackRecordsResponse,
+ };
export {
Taxonomy as Taxonomy,
diff --git a/src/resources/enrichment-status.ts b/src/resources/enrichment-status.ts
index a8fb07d..95eef0a 100644
--- a/src/resources/enrichment-status.ts
+++ b/src/resources/enrichment-status.ts
@@ -14,9 +14,13 @@ export class EnrichmentStatus extends APIResource {
* 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`. When an enrichment is not enabled
- * its counts are zero. The response contains counts only (no record identifiers or
- * content).
+ * 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).
*/
retrieve(
query: EnrichmentStatusRetrieveParams,
@@ -46,6 +50,32 @@ export interface TypeStatus {
* switched on / with a resolvable target language).
*/
enabled: boolean;
+
+ /**
+ * 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: number;
+
+ /**
+ * Eligible records the provider will never accept, 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. Retrying these cannot help; they resolve only if the
+ * text changes.
+ */
+ failed_terminal: number;
+
+ /**
+ * 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).
+ */
+ disabled_reason?: 'not_configured' | 'switched_off' | 'no_target_language';
}
/**
@@ -53,6 +83,14 @@ export interface TypeStatus {
* directory-level totals.
*/
export interface EnrichmentStatusRetrieveResponse {
+ /**
+ * 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.
+ */
+ as_of: string;
+
/**
* One enrichment's progress for a tenant. When `enabled` is false, `eligible` and
* `done` are zero.
diff --git a/src/resources/index.ts b/src/resources/index.ts
index fc2add8..7ace492 100644
--- a/src/resources/index.ts
+++ b/src/resources/index.ts
@@ -28,7 +28,11 @@ export {
type TaxonomyListFieldsResponse,
type TaxonomyListFieldsParams,
} from './taxonomy/taxonomy';
-export { Tenants, type TenantDeleteDataResponse } from './tenants/tenants';
+export {
+ Tenants,
+ type TenantDeleteDataResponse,
+ type TenantPurgeFeedbackRecordsResponse,
+} from './tenants/tenants';
export {
Webhooks,
type WebhookCreateResponse,
diff --git a/src/resources/tenants/index.ts b/src/resources/tenants/index.ts
index ab54cc0..65d3160 100644
--- a/src/resources/tenants/index.ts
+++ b/src/resources/tenants/index.ts
@@ -6,4 +6,4 @@ export {
type SettingUpdateResponse,
type SettingUpdateParams,
} from './settings';
-export { Tenants, type TenantDeleteDataResponse } from './tenants';
+export { Tenants, type TenantDeleteDataResponse, type TenantPurgeFeedbackRecordsResponse } from './tenants';
diff --git a/src/resources/tenants/tenants.ts b/src/resources/tenants/tenants.ts
index d6b3698..64d8927 100644
--- a/src/resources/tenants/tenants.ts
+++ b/src/resources/tenants/tenants.ts
@@ -41,6 +41,76 @@ export class Tenants extends APIResource {
deleteData(tenantID: string, options?: RequestOptions): APIPromise {
return this._client.delete(path`/v1/tenants/${tenantID}/data`, options);
}
+
+ /**
+ * 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.
+ *
+ * @example
+ * ```ts
+ * const response = await client.tenants.purgeFeedbackRecords(
+ * 'org-123',
+ * );
+ * ```
+ */
+ purgeFeedbackRecords(
+ tenantID: string,
+ options?: RequestOptions,
+ ): APIPromise {
+ return this._client.delete(path`/v1/tenants/${tenantID}/feedback-records`, options);
+ }
}
export interface TenantDeleteDataResponse {
@@ -79,6 +149,11 @@ export interface TenantDeleteDataResponse {
*/
deleted_taxonomy_nodes: number;
+ /**
+ * Number of immutable taxonomy run-input snapshot rows deleted
+ */
+ deleted_taxonomy_run_input_records: number;
+
/**
* Number of taxonomy runs deleted
*/
@@ -100,10 +175,32 @@ export interface TenantDeleteDataResponse {
tenant_id: string;
}
+export interface TenantPurgeFeedbackRecordsResponse {
+ /**
+ * 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.
+ */
+ status: 'accepted';
+
+ /**
+ * Tenant ID whose feedback records are being purged
+ */
+ tenant_id: string;
+
+ /**
+ * Human-readable confirmation
+ */
+ message?: string;
+}
+
Tenants.Settings = Settings;
export declare namespace Tenants {
- export { type TenantDeleteDataResponse as TenantDeleteDataResponse };
+ export {
+ type TenantDeleteDataResponse as TenantDeleteDataResponse,
+ type TenantPurgeFeedbackRecordsResponse as TenantPurgeFeedbackRecordsResponse,
+ };
export {
Settings as Settings,
diff --git a/src/version.ts b/src/version.ts
index ce6b899..9d013cc 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const VERSION = '0.12.0'; // x-release-please-version
+export const VERSION = '0.13.0'; // x-release-please-version
diff --git a/tests/api-resources/tenants/tenants.test.ts b/tests/api-resources/tenants/tenants.test.ts
index 1c48fc6..5447b1e 100644
--- a/tests/api-resources/tenants/tenants.test.ts
+++ b/tests/api-resources/tenants/tenants.test.ts
@@ -19,4 +19,16 @@ describe('resource tenants', () => {
expect(dataAndResponse.data).toBe(response);
expect(dataAndResponse.response).toBe(rawResponse);
});
+
+ // Mock server tests are disabled
+ test.skip('purgeFeedbackRecords', async () => {
+ const responsePromise = client.tenants.purgeFeedbackRecords('org-123');
+ const rawResponse = await responsePromise.asResponse();
+ expect(rawResponse).toBeInstanceOf(Response);
+ const response = await responsePromise;
+ expect(response).not.toBeInstanceOf(Response);
+ const dataAndResponse = await responsePromise.withResponse();
+ expect(dataAndResponse.data).toBe(response);
+ expect(dataAndResponse.response).toBe(rawResponse);
+ });
});