feat: enforce api_and_webhooks feature for token API and account webhooks (#14973)

This gates API-token access and outgoing account webhooks behind the
`api_and_webhooks` account feature introduced in #14972. On Chatwoot
Cloud, Hacker accounts lose token-authenticated account API access and
account webhook delivery, while paid accounts retain them through the
billing-plan feature reconcile. Community and self-hosted installations
continue to work without any upgrade-time interruption.

## What changed

- Added `Account#api_and_webhooks_enabled?` as the single backend kill
switch. Core returns enabled; the Enterprise override consults the
account flag on Chatwoot Cloud and remains enabled off-Cloud.
- Account-scoped v1 and v2 requests authenticated with a user or
agent-bot API token now return `403 Forbidden` when the feature is
disabled. Invalid tokens still return 401, and dashboard session
requests are unaffected.
- Profile responses return an empty access token when none of the user's
accounts has access. The stored token is preserved, and the profile UI
disables its token controls with paid-plan copy on Cloud.
- Account webhook delivery stops when the feature is disabled. Webhook
CRUD remains available to session-authenticated dashboard requests,
API-inbox webhooks continue to be delivered, and the Cloud dashboard
shows a webhook paywall instead of the webhook list.
- Removed the database backfill migration. Existing paid Cloud accounts
should be enabled with the one-off script below before enforcement is
deployed.

## Existing paid-account rollout

Run this as an ad-hoc Rails runner script on Chatwoot Cloud. It
intentionally targets only the Startups, Business, and Enterprise plans
and does not add `api_and_webhooks` to `manually_managed_features`, so
future billing reconciles remain authoritative.

```rb
paid_plan_names = %w[Startups Business Enterprise]
accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names)

total = accounts.count
enabled = 0
skipped = 0

puts "Enabling api_and_webhooks for #{total} paid account(s)..."

accounts.find_each(batch_size: 500).with_index(1) do |account, processed|
  if account.feature_enabled?('api_and_webhooks')
    skipped += 1
  else
    account.enable_features!('api_and_webhooks')
    enabled += 1
  end

  puts "Processed #{processed}/#{total}..." if (processed % 1000).zero?
end

puts "Done! Enabled: #{enabled}, Skipped: #{skipped}, Total: #{total}"
```

For example, save the snippet outside the repository as
`enable_api_and_webhooks.rb`, then run:

```sh
bundle exec rails runner /path/to/enable_api_and_webhooks.rb
```

## How to test

- On Cloud, use a Hacker account and confirm token-authenticated
requests to account-scoped v1 and v2 endpoints return 403, while the
same dashboard actions continue to work through session authentication.
- Confirm profile access-token controls are disabled with paid-plan copy
when all accounts are ineligible, and remain available when at least one
account has the feature.
- Confirm the Webhooks settings page shows the billing paywall for a
Cloud account without the feature; admins get the billing action and
agents get the existing ask-an-admin message.
- Confirm outgoing account webhooks stop for an ineligible Cloud account
while API-inbox webhooks still deliver.
- Confirm community and self-hosted installations retain API and webhook
behavior after upgrading, even when an existing account does not have
the stored feature bit.


### Screenshots

## Cloud

<img width="2590" height="642" alt="CleanShot 2026-07-15 at 15 13 14@2x"
src="https://github.com/user-attachments/assets/431a7bd8-1742-4e7a-b312-d3ad92015f9b"
/>

<img width="2152" height="994" alt="CleanShot 2026-07-15 at 15 14 37@2x"
src="https://github.com/user-attachments/assets/475dda48-d1c5-4be5-a3c3-7a96b9713724"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Shivam Mishra
2026-07-16 13:43:49 +04:00
committed by GitHub
co-authored by Muhsin Keloth
parent 0a25a0ef66
commit 522e3c4d3f
23 changed files with 441 additions and 12 deletions
@@ -14,6 +14,7 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -61,6 +62,7 @@ const handleClick = () => {
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
:disabled="disabled"
@click="handleClick"
@blur="resetConfirmMode"
>
+1
View File
@@ -12,6 +12,7 @@ export const FEATURE_FLAGS = {
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
DATA_IMPORT: 'data_import',
API_AND_WEBHOOKS: 'api_and_webhooks',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
@@ -31,6 +31,13 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
"PAYWALL": {
"TITLE": "Webhooks are available on paid plans",
"AVAILABLE_ON": "Use webhooks to receive real-time events from your Chatwoot account.",
"UPGRADE_PROMPT": "Upgrade to the Startups, Business, or Enterprise plan to use webhooks.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "Change or cancel your plan anytime."
},
"SECRET": {
"LABEL": "Secret",
"COPY": "Copy secret to clipboard",
@@ -100,6 +100,7 @@
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
"PAID_PLAN_NOTE": "API access tokens are available on paid plans.",
"COPY": "Copy",
"RESET": "Reset",
"CONFIRM_RESET": "Are you sure?",
@@ -5,9 +5,11 @@ import { useBranding } from 'shared/composables/useBranding';
import { picoSearch } from '@scmmishra/pico-search';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
import WebhookRow from './WebhookRow.vue';
import WebhookPaywall from './WebhookPaywall.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import SettingsLayout from '../../SettingsLayout.vue';
@@ -20,6 +22,7 @@ export default {
NewWebhook,
EditWebhook,
WebhookRow,
WebhookPaywall,
},
setup() {
const { replaceInstallationName } = useBranding();
@@ -39,7 +42,19 @@ export default {
...mapGetters({
records: 'webhooks/getWebhooks',
uiFlags: 'webhooks/getUIFlags',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
apiAndWebhooksEnabled() {
return (
!this.isOnChatwootCloud ||
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.API_AND_WEBHOOKS
)
);
},
integration() {
return this.$store.getters['integrations/getIntegration']('webhook');
},
@@ -57,9 +72,16 @@ export default {
];
},
},
watch: {
apiAndWebhooksEnabled: {
immediate: true,
handler(enabled) {
if (enabled) this.$store.dispatch('webhooks/get');
},
},
},
mounted() {
this.$store.dispatch('integrations/get', 'webhook');
this.$store.dispatch('webhooks/get');
},
methods: {
openAddPopup() {
@@ -105,10 +127,10 @@ export default {
<template>
<SettingsLayout
:is-loading="uiFlags.fetchingList"
:is-loading="apiAndWebhooksEnabled && uiFlags.fetchingList"
:loading-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LOADING')"
:no-records-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LIST.404')"
:no-records-found="!records.length"
:no-records-found="apiAndWebhooksEnabled && !records.length"
>
<template #header>
<BaseSettingsHeader
@@ -118,19 +140,21 @@ export default {
:description="replaceInstallationName(integration.description)"
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
:search-placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
apiAndWebhooksEnabled
? $t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
: ''
"
feature-name="webhook"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
>
<template v-if="records?.length" #count>
<template v-if="apiAndWebhooksEnabled && records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{
$t('INTEGRATION_SETTINGS.WEBHOOK.COUNT', { n: records.length })
}}
</span>
</template>
<template #actions>
<template v-if="apiAndWebhooksEnabled" #actions>
<NextButton
blue
:label="$t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT')"
@@ -141,7 +165,9 @@ export default {
</BaseSettingsHeader>
</template>
<template #body>
<WebhookPaywall v-if="!apiAndWebhooksEnabled" />
<BaseTable
v-else
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
@@ -160,11 +186,19 @@ export default {
</template>
</BaseTable>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<woot-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showAddPopup"
:on-close="hideAddPopup"
>
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
</woot-modal>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<woot-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showEditPopup"
:on-close="hideEditPopup"
>
<EditWebhook
v-if="showEditPopup"
:id="selectedWebHook.id"
@@ -173,6 +207,7 @@ export default {
/>
</woot-modal>
<woot-delete-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
@@ -0,0 +1,27 @@
<script setup>
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const accountId = useMapGetter('getCurrentAccountId');
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
</script>
<template>
<div class="grid place-content-center w-full h-full max-h-[28rem] mx-auto">
<BasePaywallModal
class="mx-auto"
feature-prefix="INTEGRATION_SETTINGS.WEBHOOK"
i18n-key="PAYWALL"
is-on-chatwoot-cloud
@upgrade="openBilling"
/>
</div>
</template>
@@ -6,6 +6,7 @@ import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
const props = defineProps({
value: { type: String, default: '' },
showResetButton: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['onCopy', 'onReset']);
@@ -41,12 +42,14 @@ const onReset = () => {
}"
:type="inputType"
:model-value="value"
:disabled="disabled"
readonly
>
<template #masked>
<button
class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5"
type="button"
:disabled="disabled"
@click="toggleMasked"
>
<fluent-icon :icon="maskIcon" :size="16" />
@@ -61,6 +64,7 @@ const onReset = () => {
type="button"
icon="i-lucide-copy"
class="rounded-xl"
:disabled="disabled"
@click="onClick"
/>
<ConfirmButton
@@ -73,6 +77,7 @@ const onReset = () => {
variant="outline"
icon="i-lucide-key-round"
class="rounded-xl"
:disabled="disabled"
@click="onReset"
/>
</div>
@@ -101,7 +101,24 @@ export default {
currentUser: 'getCurrentUser',
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
apiAndWebhooksEnabled() {
if (!this.isOnChatwootCloud) return true;
return this.currentUser.accounts.some(
account => account.api_and_webhooks
);
},
accessTokenDescription() {
if (!this.apiAndWebhooksEnabled) {
return this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.PAID_PLAN_NOTE');
}
return this.replaceInstallationName(
this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE')
);
},
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
@@ -191,10 +208,14 @@ export default {
useAlert(this.$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.UPDATE_SUCCESS'));
},
async onCopyToken(value) {
if (!this.apiAndWebhooksEnabled) return;
await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
async resetAccessToken() {
if (!this.apiAndWebhooksEnabled) return;
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
@@ -339,12 +360,11 @@ export default {
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
"
:description="accessTokenDescription"
>
<AccessToken
:value="currentUser.access_token"
:disabled="!apiAndWebhooksEnabled"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>