Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-camels-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/whatsapp": minor
---

Add `sendTemplate()` for sending pre-approved template messages, enabling business-initiated conversations outside the 24-hour customer service window
25 changes: 23 additions & 2 deletions apps/docs/content/adapters/official/whatsapp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ features:
scheduledMessages: no
cardFormat:
status: partial
label: WhatsApp templates
label: Interactive messages
buttons:
status: yes
label: Interactive replies
Expand All @@ -29,7 +29,7 @@ features:
tables: no
fields:
status: partial
label: Template variables
label: Formatted text
imagesInCards: yes
modals: no
slashCommands: no
Expand Down Expand Up @@ -169,6 +169,27 @@ Card elements are automatically converted to WhatsApp interactive messages:
- **More than 3 buttons** — falls back to formatted text.
- **Max body text** — 1024 characters.

### Template messages

Outside the 24-hour customer service window, WhatsApp only accepts pre-approved [template messages](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates). Use `sendTemplate` to start business-initiated conversations:

```typescript
const threadId = await adapter.openDM("15551234567");

await adapter.sendTemplate(threadId, {
name: "appointment_reminder",
language: "en",
components: [
{
type: "body",
parameters: [{ type: "text", text: "Tomorrow at 2pm" }],
},
],
});
```

Templates must be created and approved in [WhatsApp Manager](https://business.facebook.com/wa/manage/message-templates/) before they can be sent. Quick reply button taps on a template arrive as button responses and are dispatched to your `onAction` handlers.

### Thread ID format

```
Expand Down
22 changes: 22 additions & 0 deletions packages/adapter-whatsapp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export async function POST(request: Request) {
| Streaming | Buffered (accumulates then sends) |
| Mark as read | Yes |
| Auto-chunking | Yes (splits at 4096 chars) |
| Template messages | Yes (via `sendTemplate`) |

### Rich content

Expand Down Expand Up @@ -169,6 +170,27 @@ Card elements are automatically converted to WhatsApp interactive messages:
- **More than 3 buttons** — falls back to formatted text
- **Max body text** — 1024 characters

## Template messages

Outside the 24-hour customer service window, WhatsApp only accepts pre-approved [template messages](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates). Use `sendTemplate` to start business-initiated conversations:

```typescript
const threadId = await adapter.openDM("15551234567");

await adapter.sendTemplate(threadId, {
name: "appointment_reminder",
language: "en",
components: [
{
type: "body",
parameters: [{ type: "text", text: "Tomorrow at 2pm" }],
},
],
});
```

Templates must be created and approved in [WhatsApp Manager](https://business.facebook.com/wa/manage/message-templates/) before they can be sent. Quick reply button taps on a template arrive as button responses and are dispatched to your `onAction` handlers.

## Thread ID format

```
Expand Down
137 changes: 137 additions & 0 deletions packages/adapter-whatsapp/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { createWhatsAppAdapter, splitMessage, WhatsAppAdapter } from "./index";

const NOT_SUPPORTED_PATTERN = /not support/i;
const NO_MESSAGE_ID_PATTERN = /did not return a message ID/i;
const ACCESS_TOKEN_PATTERN = /accessToken/i;
const APP_SECRET_PATTERN = /appSecret/i;

Expand Down Expand Up @@ -864,6 +865,142 @@ describe("postMessage", () => {
});
});

// ---------------------------------------------------------------------------
// sendTemplate
// ---------------------------------------------------------------------------

describe("sendTemplate", () => {
let fetchSpy: MockInstance;

const makeGraphApiResponse = () =>
new Response(JSON.stringify({ messages: [{ id: "wamid.template123" }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});

beforeEach(() => {
fetchSpy = vi
.spyOn(global, "fetch")
.mockImplementation(() => Promise.resolve(makeGraphApiResponse()));
});

afterEach(() => {
fetchSpy.mockRestore();
});

it("sends a template with name and language", async () => {
const adapter = createTestAdapter();
const result = await adapter.sendTemplate(
"whatsapp:123456789:15551234567",
{
name: "appointment_reminder",
language: "en",
}
);

expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(String(url)).toContain("/123456789/messages");
const sent = JSON.parse(init?.body as string);
expect(sent.type).toBe("template");
expect(sent.to).toBe("15551234567");
expect(sent.template).toEqual({
name: "appointment_reminder",
language: { code: "en" },
});
expect(result.id).toBe("wamid.template123");
});

it("includes components when provided", async () => {
const adapter = createTestAdapter();
await adapter.sendTemplate("whatsapp:123456789:15551234567", {
name: "order_shipped",
language: "en_US",
components: [
{
type: "body",
parameters: [
{ type: "text", text: "Ada" },
{ type: "text", text: "#12345" },
],
},
{
type: "button",
sub_type: "url",
index: 0,
parameters: [{ type: "text", text: "12345" }],
},
],
});

expect(fetchSpy).toHaveBeenCalledOnce();
const sent = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(sent.template.name).toBe("order_shipped");
expect(sent.template.language).toEqual({ code: "en_US" });
expect(sent.template.components).toHaveLength(2);
expect(sent.template.components[0].parameters[0].text).toBe("Ada");
});

it("converts emoji placeholders in component parameters", async () => {
const adapter = createTestAdapter();
await adapter.sendTemplate("whatsapp:123456789:15551234567", {
name: "order_shipped",
language: "en_US",
components: [
{
type: "body",
parameters: [{ type: "text", text: "Shipped! {{emoji:thumbs_up}}" }],
},
],
});

const sent = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(sent.template.components[0].parameters[0].text).toBe("Shipped! 👍");
});

it("omits components when the array is empty", async () => {
const adapter = createTestAdapter();
await adapter.sendTemplate("whatsapp:123456789:15551234567", {
name: "hello_world",
language: "en_US",
components: [],
});

const sent = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(sent.template).not.toHaveProperty("components");
});

it("throws when the API returns no message ID", async () => {
fetchSpy.mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ messages: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
)
);

const adapter = createTestAdapter();
await expect(
adapter.sendTemplate("whatsapp:123456789:15551234567", {
name: "hello_world",
language: "en_US",
})
).rejects.toThrow(NO_MESSAGE_ID_PATTERN);
});

it("throws on invalid thread ID", async () => {
const adapter = createTestAdapter();
await expect(
adapter.sendTemplate("slack:C123:ts123", {
name: "hello_world",
language: "en_US",
})
).rejects.toThrow("Invalid WhatsApp thread ID");
expect(fetchSpy).not.toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
// editMessage
// ---------------------------------------------------------------------------
Expand Down
87 changes: 86 additions & 1 deletion packages/adapter-whatsapp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import type {
WhatsAppMediaResponse,
WhatsAppRawMessage,
WhatsAppSendResponse,
WhatsAppTemplateComponent,
WhatsAppTemplateMessage,
WhatsAppThreadId,
WhatsAppTypingIndicatorResponse,
WhatsAppWebhookPayload,
Expand Down Expand Up @@ -94,6 +96,10 @@ export type {
WhatsAppAdapterConfig,
WhatsAppMediaResponse,
WhatsAppRawMessage,
WhatsAppTemplateButtonParameter,
WhatsAppTemplateComponent,
WhatsAppTemplateMessage,
WhatsAppTemplateParameter,
WhatsAppThreadId,
} from "./types";

Expand Down Expand Up @@ -888,6 +894,85 @@ export class WhatsAppAdapter
};
}

/**
* Send a pre-approved template message via the Cloud API.
*
* Templates are the only message type WhatsApp accepts outside the
* 24-hour customer service window, making them the way to start
* business-initiated conversations. The adapter does not auto-substitute
* templates for outbound text posts — callers must opt in explicitly
* when they detect the window is closed.
*
* @example
* ```typescript
* await adapter.sendTemplate(threadId, {
* name: "appointment_reminder",
* language: "en",
* components: [
* {
* type: "body",
* parameters: [{ type: "text", text: "Tomorrow at 2pm" }],
* },
* ],
* });
* ```
*
* @see https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates
*/
async sendTemplate(
threadId: string,
template: WhatsAppTemplateMessage
): Promise<RawMessage<WhatsAppRawMessage>> {
const { userWaId } = this.decodeThreadId(threadId);

// Convert emoji placeholders in component parameters (e.g. body text
// variables), mirroring the interactive message path
const components = template.components?.length
? (JSON.parse(
convertEmojiPlaceholders(
JSON.stringify(template.components),
"whatsapp"
)
) as WhatsAppTemplateComponent[])
: undefined;

const response = await this.graphApiRequest<WhatsAppSendResponse>(
`/${this.phoneNumberId}/messages`,
{
messaging_product: "whatsapp",
recipient_type: "individual",
to: userWaId,
type: "template",
template: {
name: template.name,
language: { code: template.language },
...(components ? { components } : {}),
},
}
);

if (!(response.messages?.length && response.messages[0]?.id)) {
throw new Error(
"WhatsApp API did not return a message ID for template message"
);
}
const messageId = response.messages[0].id;

return {
id: messageId,
threadId,
raw: {
message: {
id: messageId,
from: this.phoneNumberId,
timestamp: String(Math.floor(Date.now() / 1000)),
type: "template",
},
phoneNumberId: this.phoneNumberId,
},
};
}

/**
* Edit a message. Not supported by WhatsApp Cloud API — throws an error.
*
Expand Down Expand Up @@ -1131,7 +1216,7 @@ export class WhatsAppAdapter
* For WhatsApp, this simply constructs the thread ID since all
* conversations are inherently DMs. Note: you can only message users
* who have messaged you first (within the 24-hour window) or
* via approved template messages.
* via approved template messages (see {@link sendTemplate}).
*/
async openDM(userId: string): Promise<string> {
return this.encodeThreadId({
Expand Down
Loading