diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js
index 21fd64f8d..c6945d94f 100644
--- a/app/javascript/dashboard/api/inboxes.js
+++ b/app/javascript/dashboard/api/inboxes.js
@@ -33,6 +33,13 @@ class Inboxes extends CacheEnabledApiClient {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
+ getMessageTemplates(inboxId, params = {}, config = {}) {
+ return axios.get(`${this.url}/${inboxId}/message_templates`, {
+ ...config,
+ params,
+ });
+ }
+
updateWhatsappBusinessManagementToken(inboxId, businessManagementToken) {
return axios.put(
`${this.url}/${inboxId}/whatsapp_business_management_token`,
diff --git a/app/javascript/dashboard/api/specs/inboxes.spec.js b/app/javascript/dashboard/api/specs/inboxes.spec.js
index 19aad6b3a..41d200da6 100644
--- a/app/javascript/dashboard/api/specs/inboxes.spec.js
+++ b/app/javascript/dashboard/api/specs/inboxes.spec.js
@@ -13,6 +13,7 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot');
expect(inboxesAPI).toHaveProperty('syncTemplates');
+ expect(inboxesAPI).toHaveProperty('getMessageTemplates');
});
describe('API calls', () => {
@@ -50,6 +51,24 @@ describe('#InboxesAPI', () => {
);
});
+ it('#getMessageTemplates', () => {
+ const controller = new AbortController();
+
+ inboxesAPI.getMessageTemplates(
+ 2,
+ { name: 'welcome' },
+ { signal: controller.signal }
+ );
+
+ expect(axiosMock.get).toHaveBeenCalledWith(
+ '/api/v1/inboxes/2/message_templates',
+ {
+ signal: controller.signal,
+ params: { name: 'welcome' },
+ }
+ );
+ });
+
it('#updateWhatsappBusinessManagementToken', () => {
inboxesAPI.updateWhatsappBusinessManagementToken(2, 'business-token');
expect(axiosMock.put).toHaveBeenCalledWith(
diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
index 9891b1d86..eda659765 100644
--- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
+++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
@@ -840,6 +840,12 @@ const menuItems = computed(() => {
],
to: accountScopedRoute('settings_inbox_list'),
},
+ {
+ name: 'Settings Templates',
+ label: t('SIDEBAR.WHATSAPP_TEMPLATES'),
+ icon: 'i-lucide-layout-template',
+ to: accountScopedRoute('settings_templates'),
+ },
{
name: 'Settings Labels',
label: t('SIDEBAR.LABELS'),
diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js
index 990ab9835..1b795a9a6 100644
--- a/app/javascript/dashboard/i18n/locale/en/index.js
+++ b/app/javascript/dashboard/i18n/locale/en/index.js
@@ -38,6 +38,7 @@ import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
+import whatsappTemplateMgmt from './whatsappTemplateMgmt.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
@@ -85,6 +86,7 @@ export default {
...snooze,
...teamsSettings,
...whatsappTemplates,
+ ...whatsappTemplateMgmt,
...contentTemplates,
...mfa,
...onboarding,
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index b133ca590..e36732e48 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -340,6 +340,7 @@
"AGENT_BOTS": "Bots",
"AUDIT_LOGS": "Audit Logs",
"INBOXES": "Inboxes",
+ "WHATSAPP_TEMPLATES": "Templates",
"NOTIFICATIONS": "Notifications",
"CANNED_RESPONSES": "Canned Responses",
"INTEGRATIONS": "Integrations",
diff --git a/app/javascript/dashboard/i18n/locale/en/whatsappTemplateMgmt.json b/app/javascript/dashboard/i18n/locale/en/whatsappTemplateMgmt.json
new file mode 100644
index 000000000..02831d968
--- /dev/null
+++ b/app/javascript/dashboard/i18n/locale/en/whatsappTemplateMgmt.json
@@ -0,0 +1,47 @@
+{
+ "WHATSAPP_TEMPLATE_MGMT": {
+ "TITLE": "Templates",
+ "DESCRIPTION": "View the message templates synced from your WhatsApp inboxes. To create or edit a template, manage it with your provider.",
+ "COUNT": "{n} template | {n} templates",
+ "KNOW_MORE": "Know more",
+ "NEW_TEMPLATE": "New template",
+ "SEARCH_PLACEHOLDER": "Search by content or name...",
+ "MANAGE_IN_META": "Manage in Meta",
+ "MANAGE_IN_TWILIO": "Manage in Twilio",
+ "LOADING": "Loading WhatsApp templates...",
+ "EMPTY": "No WhatsApp templates found.",
+ "NO_RESULTS": "No templates match your filters.",
+ "FETCH_ERROR": "Unable to load WhatsApp templates. Please try again.",
+ "PARTIAL_FETCH_ERROR": "Some WhatsApp templates could not be loaded. Please try again.",
+ "LAST_SYNC_ATTEMPT": "Last sync attempt on {date}",
+ "MORE_INBOXES": "+{n}",
+ "FILTERS": {
+ "ALL_INBOXES": "All inboxes",
+ "ALL_LANGUAGES": "All languages"
+ },
+ "TYPES": {
+ "TEXT": "Text",
+ "IMAGE": "Image",
+ "VIDEO": "Video",
+ "DOCUMENT": "Document",
+ "MEDIA": "Media",
+ "QUICK_REPLY": "Quick reply",
+ "CALL_TO_ACTION": "Call to action",
+ "CATALOG": "Catalog",
+ "COPY_CODE": "Copy code"
+ },
+ "PREVIEW": {
+ "TITLE": "Template preview",
+ "DESCRIPTION": "Preview how this template appears in WhatsApp.",
+ "OPEN": "Preview {name}",
+ "CLOSE": "Close preview",
+ "DETAILS": "Template details",
+ "STATUS": "Status",
+ "TYPE": "Content type",
+ "CATEGORY": "Category",
+ "LANGUAGE": "Language",
+ "INBOXES": "Inboxes",
+ "LAST_SYNC_ATTEMPT": "Last sync attempt"
+ }
+ }
+}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue
index 2fa3bf0b8..af8e4d120 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue
@@ -119,7 +119,7 @@ const helpURL = getHelpUrlForFeature(props.featureName);
>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js
index c37093176..b9c0f01da 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js
@@ -14,6 +14,7 @@ import auditlogs from './auditlogs/audit.routes';
import billing from './billing/billing.routes';
import canned from './canned/canned.routes';
import inbox from './inbox/inbox.routes';
+import templates from './templates/templates.routes';
import integrations from './integrations/integrations.routes';
import labels from './labels/labels.routes';
import macros from './macros/macros.routes';
@@ -57,6 +58,7 @@ export default {
...billing.routes,
...canned.routes,
...inbox.routes,
+ ...templates.routes,
...integrations.routes,
...data.routes,
...labels.routes,
diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/templates/Index.vue
new file mode 100644
index 000000000..fcc033ae3
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/templates/Index.vue
@@ -0,0 +1,391 @@
+
+
+
+
+
+
+
+ {{ $t('WHATSAPP_TEMPLATE_MGMT.DESCRIPTION') }}
+
+ {{ $t('WHATSAPP_TEMPLATE_MGMT.KNOW_MORE') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ $t('WHATSAPP_TEMPLATE_MGMT.COUNT', {
+ n: filteredTemplates.length,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('WHATSAPP_TEMPLATE_MGMT.NO_RESULTS') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/TemplateCard.vue b/app/javascript/dashboard/routes/dashboard/settings/templates/TemplateCard.vue
new file mode 100644
index 000000000..5cb928df4
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/templates/TemplateCard.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ template.name }}
+
+
+ {{ formatTemplateLabel(template.status) }}
+
+
+
+
+ {{
+ $t(`WHATSAPP_TEMPLATE_MGMT.TYPES.${templateTypeKey(template)}`)
+ }}
+
+
+
{{ formatTemplateLanguage(template.language) }}
+
+
{{ template.inboxNames }}
+
+
+ {{
+ $t('WHATSAPP_TEMPLATE_MGMT.LAST_SYNC_ATTEMPT', {
+ date: formatTemplateDate(template.lastUpdatedAt),
+ })
+ }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/TemplatePreviewDrawer.vue b/app/javascript/dashboard/routes/dashboard/settings/templates/TemplatePreviewDrawer.vue
new file mode 100644
index 000000000..d524994a6
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/templates/TemplatePreviewDrawer.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.DETAILS') }}
+
+
+ -
+ {{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.STATUS') }}
+
+ -
+
+ {{ formatTemplateLabel(template.status) }}
+
+
+ -
+ {{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.TYPE') }}
+
+ -
+ {{
+ $t(`WHATSAPP_TEMPLATE_MGMT.TYPES.${templateTypeKey(template)}`)
+ }}
+
+ -
+ {{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.CATEGORY') }}
+
+ -
+ {{ formatTemplateLabel(template.category) }}
+
+ -
+ {{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.LANGUAGE') }}
+
+ -
+ {{ formatTemplateLanguage(template.language) }}
+
+ -
+ {{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.INBOXES') }}
+
+ - {{ template.inboxNames }}
+ -
+ {{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.LAST_SYNC_ATTEMPT') }}
+
+ -
+ {{ formatTemplateDate(template.lastUpdatedAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/specs/templateUtils.spec.js b/app/javascript/dashboard/routes/dashboard/settings/templates/specs/templateUtils.spec.js
new file mode 100644
index 000000000..8ca8d9a6b
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/templates/specs/templateUtils.spec.js
@@ -0,0 +1,146 @@
+import { INBOX_TYPES } from 'dashboard/helper/inbox';
+import { groupTemplates } from '../templateUtils';
+
+const whatsappInbox = ({
+ id,
+ name,
+ businessAccountId,
+ provider = 'whatsapp_cloud',
+}) => ({
+ id,
+ name,
+ channel_type: INBOX_TYPES.WHATSAPP,
+ provider,
+ provider_config: { business_account_id: businessAccountId },
+});
+
+describe('#groupTemplates', () => {
+ it('uses the newest cache fields when grouping a shared provider template', () => {
+ const olderInbox = whatsappInbox({
+ id: 1,
+ name: 'Older inbox',
+ businessAccountId: 'waba-1',
+ });
+ const newerInbox = whatsappInbox({
+ id: 2,
+ name: 'Newer inbox',
+ businessAccountId: 'waba-1',
+ });
+
+ const result = groupTemplates([
+ {
+ inbox: olderInbox,
+ lastUpdatedAt: '2026-08-01T10:00:00.000Z',
+ template: {
+ id: 'template-1',
+ name: 'order_update',
+ language: 'en_US',
+ status: 'pending',
+ components: [{ type: 'BODY', text: 'Old body' }],
+ },
+ },
+ {
+ inbox: newerInbox,
+ lastUpdatedAt: '2026-08-02T10:00:00.000Z',
+ template: {
+ id: 'template-1',
+ name: 'order_update',
+ language: 'en_US',
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'New body' }],
+ },
+ },
+ ]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ status: 'approved',
+ components: [{ type: 'BODY', text: 'New body' }],
+ inboxNames: 'Older inbox, Newer inbox',
+ lastUpdatedAt: '2026-08-02T10:00:00.000Z',
+ });
+ expect(result[0].inboxes).toEqual([olderInbox, newerInbox]);
+ });
+
+ it('keeps delimiter-containing provider identities separate', () => {
+ const result = groupTemplates([
+ {
+ inbox: whatsappInbox({
+ id: 1,
+ name: 'Delimiter A',
+ businessAccountId: 'account:a',
+ }),
+ template: {
+ id: 'template',
+ name: 'delimiter_a',
+ language: 'en_US',
+ },
+ },
+ {
+ inbox: whatsappInbox({
+ id: 2,
+ name: 'Delimiter B',
+ businessAccountId: 'account',
+ }),
+ template: {
+ id: 'a:template',
+ name: 'delimiter_b',
+ language: 'en_US',
+ },
+ },
+ ]);
+
+ expect(result).toHaveLength(2);
+ expect(result.map(template => template.inboxes)).toEqual([
+ [
+ whatsappInbox({
+ id: 1,
+ name: 'Delimiter A',
+ businessAccountId: 'account:a',
+ }),
+ ],
+ [
+ whatsappInbox({
+ id: 2,
+ name: 'Delimiter B',
+ businessAccountId: 'account',
+ }),
+ ],
+ ]);
+ });
+
+ it('scopes templates without provider identities to their inbox and content', () => {
+ const result = groupTemplates([
+ {
+ inbox: whatsappInbox({
+ id: 1,
+ name: 'First inbox',
+ businessAccountId: 'waba-1',
+ }),
+ template: {
+ name: 'shared_name',
+ language: 'en_US',
+ components: [{ type: 'BODY', text: 'First body' }],
+ },
+ },
+ {
+ inbox: whatsappInbox({
+ id: 2,
+ name: 'Second inbox',
+ businessAccountId: 'waba-1',
+ }),
+ template: {
+ name: 'shared_name',
+ language: 'en_US',
+ components: [{ type: 'BODY', text: 'Second body' }],
+ },
+ },
+ ]);
+
+ expect(result).toHaveLength(2);
+ expect(result.map(template => template.searchableContent)).toEqual([
+ '[{"type":"BODY","text":"First body"}]',
+ '[{"type":"BODY","text":"Second body"}]',
+ ]);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/templateUtils.js b/app/javascript/dashboard/routes/dashboard/settings/templates/templateUtils.js
new file mode 100644
index 000000000..90808720a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/templates/templateUtils.js
@@ -0,0 +1,137 @@
+import { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
+import { INBOX_TYPES } from 'dashboard/helper/inbox';
+import {
+ PLATFORMS,
+ TEMPLATE_TYPES,
+} from 'dashboard/services/TemplateConstants';
+
+const TEMPLATE_TYPE_KEYS = {
+ [TEMPLATE_TYPES.WHATSAPP_TEXT]: 'TEXT',
+ [TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER]: 'TEXT',
+ [TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE]: 'IMAGE',
+ [TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO]: 'VIDEO',
+ [TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT]: 'DOCUMENT',
+ [TEMPLATE_TYPES.WHATSAPP_INTERACTIVE]: 'CALL_TO_ACTION',
+ [TEMPLATE_TYPES.WHATSAPP_COPY_CODE]: 'COPY_CODE',
+ [TEMPLATE_TYPES.TWILIO_TEXT]: 'TEXT',
+ [TEMPLATE_TYPES.TWILIO_MEDIA]: 'MEDIA',
+ [TEMPLATE_TYPES.TWILIO_QUICK_REPLY]: 'QUICK_REPLY',
+ [TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION]: 'CALL_TO_ACTION',
+ [TEMPLATE_TYPES.TWILIO_CARD]: 'CATALOG',
+};
+
+export const groupTemplates = templateRecords => {
+ const groupedTemplates = new Map();
+
+ templateRecords.forEach(({ template, inbox, lastUpdatedAt }) => {
+ const platform =
+ inbox.channel_type === INBOX_TYPES.TWILIO
+ ? PLATFORMS.TWILIO
+ : PLATFORMS.WHATSAPP;
+ const providerAccountId =
+ inbox.provider_config?.business_account_id ||
+ inbox.account_sid ||
+ inbox.id;
+ const name = template.name || template.friendly_name;
+ const providerTemplateIdentifier = template.id || template.content_sid;
+ const templateIdentifier = providerTemplateIdentifier || name;
+ const key = JSON.stringify(
+ providerTemplateIdentifier
+ ? [
+ platform,
+ providerAccountId,
+ providerTemplateIdentifier,
+ template.language,
+ ]
+ : [platform, inbox.id, name, template.language, template]
+ );
+ const searchableContent = JSON.stringify(
+ template.components || template.types || template.body || []
+ );
+ const normalizedTemplate = {
+ ...template,
+ id: templateIdentifier,
+ name,
+ platform,
+ key,
+ inboxes: [inbox],
+ inboxNames: inbox.name,
+ lastUpdatedAt,
+ searchableContent,
+ };
+ const existingTemplate = groupedTemplates.get(key);
+
+ if (existingTemplate) {
+ const inboxes = [...existingTemplate.inboxes, inbox];
+ const incomingIsNewer =
+ lastUpdatedAt &&
+ (!existingTemplate.lastUpdatedAt ||
+ new Date(lastUpdatedAt) > new Date(existingTemplate.lastUpdatedAt));
+
+ groupedTemplates.set(key, {
+ ...(incomingIsNewer ? normalizedTemplate : existingTemplate),
+ inboxes,
+ inboxNames: inboxes.map(item => item.name).join(', '),
+ lastUpdatedAt: incomingIsNewer
+ ? lastUpdatedAt
+ : existingTemplate.lastUpdatedAt,
+ });
+ return;
+ }
+
+ groupedTemplates.set(key, normalizedTemplate);
+ });
+
+ return [...groupedTemplates.values()].sort((first, second) =>
+ first.name.localeCompare(second.name)
+ );
+};
+
+export const formatTemplateLabel = value => {
+ if (!value) return '—';
+
+ return value
+ .toLowerCase()
+ .replaceAll('_', ' ')
+ .replace(/\b\w/g, character => character.toUpperCase());
+};
+
+export const formatTemplateLanguage = language => {
+ if (!language) return '—';
+
+ const locale = language.replace('_', '-');
+ const languageCode = locale.split('-')[0];
+ const displayName = new Intl.DisplayNames([locale], {
+ type: 'language',
+ }).of(languageCode);
+
+ return `${displayName} (${locale})`;
+};
+
+export const formatTemplateDate = value => {
+ if (!value) return '—';
+
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ }).format(new Date(value));
+};
+
+export const templateStatusClasses = status => {
+ const classes = {
+ approved: 'bg-n-teal-3 text-n-teal-11',
+ pending: 'bg-n-amber-3 text-n-amber-11',
+ rejected: 'bg-n-ruby-3 text-n-ruby-11',
+ paused: 'bg-n-amber-3 text-n-amber-11',
+ disabled: 'bg-n-alpha-2 text-n-slate-11',
+ };
+
+ return classes[status?.toLowerCase()] || 'bg-n-alpha-2 text-n-slate-11';
+};
+
+export const templateTypeKey = template => {
+ const type =
+ template.platform === PLATFORMS.TWILIO
+ ? TemplateTypeDetector.detectTwilioType(template)
+ : TemplateTypeDetector.detectWhatsAppType(template);
+ return TEMPLATE_TYPE_KEYS[type] || 'TEXT';
+};
diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/templates.routes.js b/app/javascript/dashboard/routes/dashboard/settings/templates/templates.routes.js
new file mode 100644
index 000000000..505abe53a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/templates/templates.routes.js
@@ -0,0 +1,23 @@
+import { frontendURL } from '../../../../helper/URLHelper';
+
+import SettingsWrapper from '../SettingsWrapper.vue';
+import Index from './Index.vue';
+
+export default {
+ routes: [
+ {
+ path: frontendURL('accounts/:accountId/settings/templates'),
+ component: SettingsWrapper,
+ children: [
+ {
+ path: '',
+ name: 'settings_templates',
+ component: Index,
+ meta: {
+ permissions: ['administrator'],
+ },
+ },
+ ],
+ },
+ ],
+};
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index 64a29c215..ed2db91bb 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -170,8 +170,10 @@ export const actions = {
const response = await InboxesAPI.get(true);
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
commit(types.default.SET_INBOXES, response.data.payload);
+ return true;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
+ return false;
}
},
createChannel: async ({ commit }, params) => {