feat: sign webhooks for API channel and agentbots (#13892)

Account webhooks sign outgoing payloads with HMAC-SHA256, but agent bot
and API inbox webhooks were delivered unsigned. This PR adds the same
signing to both.

Each model gets a dedicated `secret` column rather than reusing the
agent bot's `access_token` (for API auth back into Chatwoot) or the API
inbox's `hmac_token` (for inbound contact identity verification). These
serve different trust boundaries and shouldn't be coupled — rotating a
signing secret shouldn't invalidate API access or contact verification.

The existing `Webhooks::Trigger` already signs when a secret is present,
so the backend change is just passing `secret:` through to the jobs.
Shared token logic is extracted into a `WebhookSecretable` concern
included by `Webhook`, `AgentBot`, and `Channel::Api`. The frontend
reuses the existing `AccessToken` component for secret display. Secrets
are admin-only and excluded from enterprise audit logs.

### How to test

Point an agent bot or API inbox webhook URL at a request inspector. Send
a message and verify `X-Chatwoot-Signature` and `X-Chatwoot-Timestamp`
headers are present. Reset the secret from settings and confirm
subsequent deliveries use the new value.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Shivam Mishra
2026-04-06 15:28:25 +05:30
committed by GitHub
parent f4d66566d0
commit 95463230cb
32 changed files with 273 additions and 28 deletions

View File

@@ -25,6 +25,10 @@ class AgentBotsAPI extends ApiClient {
resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`);
}
resetSecret(botId) {
return axios.post(`${this.url}/${botId}/reset_secret`);
}
}
export default new AgentBotsAPI();

View File

@@ -48,6 +48,10 @@ class Inboxes extends CacheEnabledApiClient {
template,
});
}
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
}
export default new Inboxes();

View File

@@ -63,6 +63,16 @@
"ERROR_MESSAGE": "Could not update bot. Please try again."
}
},
"SECRET": {
"LABEL": "Webhook Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"CREATED_DESC": "Use the secret below to verify webhook signatures. Please copy it now, you can also find it later in the bot settings.",
"DONE": "Done",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely",

View File

@@ -86,6 +86,14 @@
"PLACEHOLDER": "Please enter your Webhook URL",
"ERROR": "Please enter a valid URL"
},
"CHANNEL_WEBHOOK_SECRET": {
"LABEL": "Webhook Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"CHANNEL_DOMAIN": {
"LABEL": "Website Domain",
"PLACEHOLDER": "Enter your website domain (eg: acme.com)"

View File

@@ -47,6 +47,7 @@ const formState = reactive({
const [showAccessToken, toggleAccessToken] = useToggle();
const accessToken = ref('');
const botSecret = ref('');
const v$ = useVuelidate(
{
@@ -179,15 +180,21 @@ const handleSubmit = async () => {
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
useAlert(alertKey);
// Show access token after creation
// Show access token and secret after creation
if (isCreate) {
const { access_token: responseAccessToken, id } = response || {};
const {
access_token: responseAccessToken,
secret: responseSecret,
id,
} = response || {};
if (id && responseAccessToken) {
accessToken.value = responseAccessToken;
botSecret.value = responseSecret || '';
toggleAccessToken(true);
} else {
accessToken.value = '';
botSecret.value = '';
dialogRef.value.close();
}
} else {
@@ -212,14 +219,16 @@ const initializeForm = () => {
thumbnail,
bot_config: botConfig,
access_token: botAccessToken,
secret: botSecretValue,
} = props.selectedBot;
formState.botName = name || '';
formState.botDescription = description || '';
formState.botUrl = botUrl || botConfig?.webhook_url || '';
formState.botAvatarUrl = thumbnail || '';
if (botAccessToken && props.type === MODAL_TYPES.EDIT) {
accessToken.value = botAccessToken;
if (props.type === MODAL_TYPES.EDIT) {
if (botAccessToken) accessToken.value = botAccessToken;
if (botSecretValue) botSecret.value = botSecretValue;
}
} else {
resetForm();
@@ -231,6 +240,24 @@ const onCopyToken = async value => {
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL'));
};
const onCopySecret = async value => {
await copyTextToClipboard(value || botSecret.value);
useAlert(t('AGENT_BOTS.SECRET.COPY_SUCCESS'));
};
const onResetSecret = async () => {
const response = await store.dispatch(
'agentBots/resetSecret',
props.selectedBot.id
);
if (response) {
botSecret.value = response.secret;
useAlert(t('AGENT_BOTS.SECRET.RESET_SUCCESS'));
} else {
useAlert(t('AGENT_BOTS.SECRET.RESET_ERROR'));
}
};
const onResetToken = async () => {
const response = await store.dispatch(
'agentBots/resetAccessToken',
@@ -247,6 +274,7 @@ const onResetToken = async () => {
const closeModal = () => {
if (!showAccessToken.value) v$.value?.$reset();
accessToken.value = '';
botSecret.value = '';
toggleAccessToken(false);
};
@@ -318,6 +346,20 @@ defineExpose({ dialogRef });
/>
</div>
<div
v-if="botSecret && type === MODAL_TYPES.EDIT"
class="flex flex-col gap-1"
>
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.SECRET.LABEL') }}
</label>
<AccessToken
:value="botSecret"
@on-copy="onCopySecret"
@on-reset="onResetSecret"
/>
</div>
<div v-if="showAccessTokenInput" class="flex flex-col gap-1">
<label
v-if="type === MODAL_TYPES.EDIT"
@@ -339,6 +381,23 @@ defineExpose({ dialogRef });
/>
</div>
<div
v-if="botSecret && showAccessToken && type === MODAL_TYPES.CREATE"
class="flex flex-col gap-1"
>
<p class="text-sm text-n-slate-11">
{{ $t('AGENT_BOTS.SECRET.CREATED_DESC') }}
</p>
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.SECRET.LABEL') }}
</label>
<AccessToken
:value="botSecret"
:show-reset-button="false"
@on-copy="onCopySecret"
/>
</div>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
<NextButton
faded

View File

@@ -38,6 +38,8 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
export default {
components: {
@@ -69,6 +71,7 @@ export default {
SelectInput,
AccountHealth,
Widget,
AccessToken,
},
mixins: [inboxMixin],
setup() {
@@ -362,6 +365,33 @@ export default {
this.fetchSharedData();
},
methods: {
async copyWebhookSecret(value) {
await copyTextToClipboard(value);
useAlert(
this.$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.COPY_SUCCESS'
)
);
},
async resetWebhookSecret() {
const response = await this.$store.dispatch(
'inboxes/resetSecret',
this.inbox.id
);
if (response) {
useAlert(
this.$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.RESET_SUCCESS'
)
);
} else {
useAlert(
this.$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.RESET_ERROR'
)
);
}
},
fetchSharedData() {
this.$store.dispatch('agents/get');
this.$store.dispatch('teams/get');
@@ -714,6 +744,21 @@ export default {
/>
</SettingsFieldSection>
<SettingsFieldSection
v-if="isAPIInbox && inbox.secret"
:label="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.LABEL'
)
"
>
<AccessToken
:value="inbox.secret"
@on-copy="copyWebhookSecret"
@on-reset="resetWebhookSecret"
/>
</SettingsFieldSection>
<SettingsFieldSection
v-if="isAWebWidgetInbox"
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL')"

View File

@@ -183,6 +183,17 @@ export const actions = {
return null;
}
},
resetSecret: async ({ commit }, botId) => {
try {
const response = await AgentBotsAPI.resetSecret(botId);
commit(types.EDIT_AGENT_BOT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
}
},
};
export const mutations = {

View File

@@ -366,6 +366,16 @@ export const actions = {
);
return response.data;
},
resetSecret: async ({ commit }, inboxId) => {
try {
const response = await InboxesAPI.resetSecret(inboxId);
commit(types.default.EDIT_INBOXES, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
}
},
};
export const mutations = {