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>
158 lines
6.4 KiB
Ruby
158 lines
6.4 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe 'Webhooks API', type: :request do
|
|
let(:account) { create(:account) }
|
|
let(:inbox) { create(:inbox, account: account) }
|
|
let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com', name: 'My Webhook') }
|
|
let(:administrator) { create(:user, account: account, role: :administrator) }
|
|
let(:agent) { create(:user, account: account, role: :agent) }
|
|
|
|
describe 'GET /api/v1/accounts/<account_id>/webhooks' do
|
|
context 'when it is an authenticated agent' do
|
|
it 'returns unauthorized' do
|
|
get "/api/v1/accounts/#{account.id}/webhooks",
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated admin user' do
|
|
it 'gets all webhook' do
|
|
get "/api/v1/accounts/#{account.id}/webhooks",
|
|
headers: administrator.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:success)
|
|
expect(response.parsed_body['payload']['webhooks'].count).to eql account.webhooks.count
|
|
end
|
|
end
|
|
|
|
context 'when api_and_webhooks feature is disabled' do
|
|
it 'allows session authenticated admins to manage webhooks' do
|
|
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
|
account.disable_features!('api_and_webhooks')
|
|
get "/api/v1/accounts/#{account.id}/webhooks",
|
|
headers: administrator.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'POST /api/v1/accounts/<account_id>/webhooks' do
|
|
context 'when it is an authenticated agent' do
|
|
it 'returns unauthorized' do
|
|
post "/api/v1/accounts/#{account.id}/webhooks",
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated admin user' do
|
|
it 'creates webhook' do
|
|
post "/api/v1/accounts/#{account.id}/webhooks",
|
|
params: { account_id: account.id, inbox_id: inbox.id, url: 'https://hello.com' },
|
|
headers: administrator.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:success)
|
|
|
|
expect(response.parsed_body['payload']['webhook']['url']).to eql 'https://hello.com'
|
|
end
|
|
|
|
it 'creates webhook with name' do
|
|
post "/api/v1/accounts/#{account.id}/webhooks",
|
|
params: { account_id: account.id, inbox_id: inbox.id, url: 'https://hello.com', name: 'My Webhook' },
|
|
headers: administrator.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:success)
|
|
|
|
expect(response.parsed_body['payload']['webhook']['name']).to eql 'My Webhook'
|
|
end
|
|
|
|
it 'throws error when invalid url provided' do
|
|
post "/api/v1/accounts/#{account.id}/webhooks",
|
|
params: { account_id: account.id, inbox_id: inbox.id, url: 'javascript:alert(1)' },
|
|
headers: administrator.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body['message']).to eql 'Url is invalid'
|
|
end
|
|
|
|
it 'throws error if subscription events are invalid' do
|
|
post "/api/v1/accounts/#{account.id}/webhooks",
|
|
params: { url: 'https://hello.com', subscriptions: ['conversation_random_event'] },
|
|
headers: administrator.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body['message']).to eql 'Subscriptions Invalid events'
|
|
end
|
|
|
|
it 'throws error if subscription events are empty' do
|
|
post "/api/v1/accounts/#{account.id}/webhooks",
|
|
params: { url: 'https://hello.com', subscriptions: [] },
|
|
headers: administrator.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body['message']).to eql 'Subscriptions Invalid events'
|
|
end
|
|
|
|
it 'use default if subscription events are nil' do
|
|
post "/api/v1/accounts/#{account.id}/webhooks",
|
|
params: { url: 'https://hello.com', subscriptions: nil },
|
|
headers: administrator.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:ok)
|
|
expect(
|
|
response.parsed_body['payload']['webhook']['subscriptions']
|
|
).to eql %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
|
|
message_created message_updated webwidget_triggered]
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'PUT /api/v1/accounts/<account_id>/webhooks/:id' do
|
|
context 'when it is an authenticated agent' do
|
|
it 'returns unauthorized' do
|
|
put "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated admin user' do
|
|
it 'updates webhook' do
|
|
put "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
|
|
params: { url: 'https://hello.com', name: 'Another Webhook' },
|
|
headers: administrator.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:success)
|
|
expect(response.parsed_body['payload']['webhook']['url']).to eql 'https://hello.com'
|
|
expect(response.parsed_body['payload']['webhook']['name']).to eql 'Another Webhook'
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'DELETE /api/v1/accounts/<account_id>/webhooks/:id' do
|
|
context 'when it is an authenticated agent' do
|
|
it 'returns unauthorized' do
|
|
delete "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated admin user' do
|
|
it 'deletes webhook' do
|
|
delete "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
|
|
headers: administrator.create_new_auth_token,
|
|
as: :json
|
|
expect(response).to have_http_status(:success)
|
|
expect(account.webhooks.count).to be 0
|
|
end
|
|
end
|
|
end
|
|
end
|