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,8 +25,10 @@ describe AgentBotListener do
context 'when agent bot is configured' do
it 'sends message to agent bot' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
message.webhook_data.merge(event: 'message_created')).once
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
agent_bot.outgoing_url, message.webhook_data.merge(event: 'message_created'),
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
).once
listener.message_created(event)
end
@@ -48,8 +50,14 @@ describe AgentBotListener do
it 'sends message to both bots exactly once' do
payload = message.webhook_data.merge(event: 'message_created')
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, payload).once
expect(AgentBots::WebhookJob).to receive(:perform_later).with(conversation_bot.outgoing_url, payload).once
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
agent_bot.outgoing_url, payload, :agent_bot_webhook,
secret: agent_bot.secret, delivery_id: instance_of(String)
).once
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
conversation_bot.outgoing_url, payload, :agent_bot_webhook,
secret: conversation_bot.secret, delivery_id: instance_of(String)
).once
listener.message_created(event)
end
@@ -74,9 +82,11 @@ describe AgentBotListener do
it 'sends webhook to the inbox agent bot with changed_attributes' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
conversation.webhook_data.merge(event: 'conversation_updated',
changed_attributes: nil)).once
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
agent_bot.outgoing_url,
conversation.webhook_data.merge(event: 'conversation_updated', changed_attributes: nil),
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
).once
listener.conversation_updated(event)
end
end
@@ -93,11 +103,14 @@ describe AgentBotListener do
it 'sends webhook with changed_attributes to the assigned agent bot' do
expected_changed_attributes = [{ 'assignee_agent_bot_id' => { previous_value: nil, current_value: agent_bot.id } }]
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
conversation.webhook_data.merge(
event: 'conversation_updated',
changed_attributes: expected_changed_attributes
)).once
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
agent_bot.outgoing_url,
conversation.webhook_data.merge(
event: 'conversation_updated',
changed_attributes: expected_changed_attributes
),
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
).once
listener.conversation_updated(event)
end
end
@@ -121,7 +134,8 @@ describe AgentBotListener do
expect(AgentBots::WebhookJob).to receive(:perform_later)
.with(
agent_bot.outgoing_url,
conversation.contact_inbox.webhook_data.merge(event: 'webwidget_triggered', event_info: { country: 'US' })
conversation.contact_inbox.webhook_data.merge(event: 'webwidget_triggered', event_info: { country: 'US' }),
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
).once
listener.webwidget_triggered(event)

View File

@@ -59,7 +59,7 @@ describe WebhookListener do
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
:api_inbox_webhook, delivery_id: instance_of(String)
:api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
).once
listener.message_created(api_event)
end
@@ -112,7 +112,7 @@ describe WebhookListener do
expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url,
api_conversation.webhook_data.merge(event: 'conversation_created'),
:api_inbox_webhook, delivery_id: instance_of(String)
:api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
).once
listener.conversation_created(api_event)
end
@@ -348,7 +348,7 @@ describe WebhookListener do
expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url, payload, :api_inbox_webhook,
delivery_id: instance_of(String)
secret: channel_api.secret, delivery_id: instance_of(String)
).once
listener.conversation_typing_on(api_event)
end