Send text, media, location, interactive, contact, and reaction messages on WhatsApp
The WhatsApp channel ships in @betternotify/whatsapp. It exposes ten actions: text, image, video, document, audio, location, reaction, interactive, contacts, and template.
npm install @betternotify/whatsappSetup
import { createNotify } from '@betternotify/core';
import { whatsappChannel } from '@betternotify/whatsapp';
const rpc = createNotify({ channels: { whatsapp: whatsappChannel() } });Actions
Pick an action with rpc.whatsapp().<action>():
rpc.whatsapp().text() // plain text message
rpc.whatsapp().image() // image with optional caption
rpc.whatsapp().video() // video with optional caption
rpc.whatsapp().document() // file attachment
rpc.whatsapp().audio() // audio message
rpc.whatsapp().location() // location pin
rpc.whatsapp().reaction() // emoji reaction to an existing message
rpc.whatsapp().interactive() // buttons or list menus
rpc.whatsapp().contacts() // contact cards
rpc.whatsapp().template() // Meta-approved business templateEach action has its own slots and send arguments. A single catalog can mix any of them.
Common send arguments
All actions share these send arguments:
Prop
Type
Text
Sends a plain text message. Requires a body slot.
Slots
Prop
Type
Example
const catalog = rpc.catalog({
orderConfirm: rpc
.whatsapp()
.text()
.input(z.object({ orderId: z.string(), total: z.string() }))
.body(({ input }) => `Order ${input.orderId} confirmed! Total: ${input.total}`),
});
await notify.orderConfirm.send({
to: '+5511999999999',
input: { orderId: 'ORD-1234', total: 'R$ 199,90' },
});Image
Sends an image with an optional caption. Provide either url or data + mimeType.
Slots
Prop
Type
Example
const catalog = rpc.catalog({
shippingPhoto: rpc
.whatsapp()
.image()
.input(z.object({ orderId: z.string(), photoUrl: z.string() }))
.url(({ input }) => input.photoUrl)
.caption(({ input }) => `Order ${input.orderId} is packed and ready!`),
});
await notify.shippingPhoto.send({
to: '+5511999999999',
input: { orderId: 'ORD-1234', photoUrl: 'https://cdn.example.com/packages/ORD-1234.jpg' },
});Video
Sends a video with an optional caption. Requires a url slot.
Slots
Prop
Type
Example
const catalog = rpc.catalog({
tutorialClip: rpc
.whatsapp()
.video()
.input(z.object({ title: z.string(), videoUrl: z.string() }))
.url(({ input }) => input.videoUrl)
.caption(({ input }) => input.title),
});
await notify.tutorialClip.send({
to: '+5511999999999',
input: { title: 'How to use your new device', videoUrl: 'https://cdn.example.com/tutorials/setup.mp4' },
});Document
Sends a file attachment with optional caption and filename. Requires a url slot.
Slots
Prop
Type
Example
const catalog = rpc.catalog({
invoice: rpc
.whatsapp()
.document()
.input(z.object({ orderId: z.string(), invoiceUrl: z.string() }))
.url(({ input }) => input.invoiceUrl)
.filename(({ input }) => `invoice-${input.orderId}.pdf`)
.caption('Here is your invoice.'),
});
await notify.invoice.send({
to: '+5511999999999',
input: { orderId: 'ORD-1234', invoiceUrl: 'https://cdn.example.com/invoices/ORD-1234.pdf' },
});Audio
Sends an audio message. Requires a url slot.
Slots
Prop
Type
Example
const catalog = rpc.catalog({
voiceNote: rpc
.whatsapp()
.audio()
.input(z.object({ audioUrl: z.string() }))
.url(({ input }) => input.audioUrl),
});
await notify.voiceNote.send({
to: '+5511999999999',
input: { audioUrl: 'https://cdn.example.com/audio/welcome.ogg' },
});Location
Sends a location pin on the map. Requires latitude and longitude slots.
Slots
Prop
Type
Example
const catalog = rpc.catalog({
storeLocation: rpc
.whatsapp()
.location()
.input(z.object({ storeName: z.string(), lat: z.number(), lng: z.number() }))
.latitude(({ input }) => input.lat)
.longitude(({ input }) => input.lng)
.name(({ input }) => input.storeName)
.address('123 Main St, São Paulo, SP'),
});
await notify.storeLocation.send({
to: '+5511999999999',
input: { storeName: 'Loja Centro', lat: -23.5505, lng: -46.6333 },
});Reaction
Reacts to an existing message with an emoji. Requires an emoji slot and a messageId send argument.
Slots
Prop
Type
Send arguments
In addition to to and input, reactions require:
Prop
Type
Example
const catalog = rpc.catalog({
ackReaction: rpc
.whatsapp()
.reaction()
.input(z.object({ emoji: z.string() }))
.emoji(({ input }) => input.emoji),
});
await notify.ackReaction.send({
to: '+5511999999999',
messageId: 'wamid.HBgNNTUx...',
input: { emoji: '👍' },
});Interactive
Sends an interactive message with reply buttons or list menus. Requires a body slot. Use either buttons or sections, not both.
Slots
Prop
Type
Buttons example
const catalog = rpc.catalog({
feedbackRequest: rpc
.whatsapp()
.interactive()
.input(z.object({ orderId: z.string() }))
.body(({ input }) => `How was your experience with order ${input.orderId}?`)
.header('We value your feedback')
.footer('Reply within 24h')
.buttons([
{ id: 'great', title: 'Great!' },
{ id: 'ok', title: 'It was OK' },
{ id: 'bad', title: 'Not good' },
]),
});
await notify.feedbackRequest.send({
to: '+5511999999999',
input: { orderId: 'ORD-1234' },
});List menu example
const catalog = rpc.catalog({
productPicker: rpc
.whatsapp()
.interactive()
.input(z.object({ category: z.string() }))
.body(({ input }) => `Browse our ${input.category} collection:`)
.header('Shop Now')
.sections([
{
title: 'Popular',
rows: [
{ id: 'prod-1', title: 'Classic T-Shirt', description: 'R$ 79,90' },
{ id: 'prod-2', title: 'Slim Jeans', description: 'R$ 149,90' },
],
},
{
title: 'New Arrivals',
rows: [
{ id: 'prod-3', title: 'Summer Dress', description: 'R$ 199,90' },
],
},
]),
});
await notify.productPicker.send({
to: '+5511999999999',
input: { category: 'clothing' },
});Contacts
Shares one or more contact cards. Requires a contacts slot.
Slots
Prop
Type
Example
const catalog = rpc.catalog({
shareAgent: rpc
.whatsapp()
.contacts()
.input(z.object({ agentName: z.string(), agentPhone: z.string() }))
.contacts(({ input }) => [
{
name: { formatted: input.agentName },
phones: [{ phone: input.agentPhone, type: 'WORK' }],
},
]),
});
await notify.shareAgent.send({
to: '+5511999999999',
input: { agentName: 'Maria Silva', agentPhone: '+5511988887777' },
});Template
Sends a Meta-approved business template. This is the only message type allowed for business-initiated conversations outside the 24-hour customer-service window. The template must be registered and approved in the Meta Business Manager before use.
Slots
Prop
Type
Example
const catalog = rpc.catalog({
orderShipped: rpc
.whatsapp()
.template()
.input(z.object({ orderId: z.string(), trackingCode: z.string() }))
.name('order_shipped')
.language('pt_BR')
.components(({ input }) => [
{
type: 'body',
parameters: [
{ type: 'text', text: input.orderId },
{ type: 'text', text: input.trackingCode },
],
},
{
type: 'button',
sub_type: 'url',
index: '0',
parameters: [{ type: 'text', text: input.trackingCode }],
},
]),
});
await notify.orderShipped.send({
to: '+5511999999999',
input: { orderId: 'ORD-1234', trackingCode: 'BR987654321' },
});Meta enforces that components parameter counts and types match the registered template at send time. Mismatches surface as VALIDATION provider errors (codes 132000–132012).
Full example
All nine actions in a single catalog:
import { createNotify, createClient } from '@betternotify/core';
import { createMockTransport } from '@betternotify/core/transports';
import { whatsappChannel } from '@betternotify/whatsapp';
import type { RenderedWhatsApp } from '@betternotify/whatsapp';
import { z } from 'zod';
const rpc = createNotify({ channels: { whatsapp: whatsappChannel() } });
const catalog = rpc.catalog({
orderConfirm: rpc
.whatsapp()
.text()
.input(z.object({ orderId: z.string(), total: z.string() }))
.body(({ input }) => `Order ${input.orderId} confirmed! Total: ${input.total}`),
shippingPhoto: rpc
.whatsapp()
.image()
.input(z.object({ orderId: z.string(), photoUrl: z.string() }))
.url(({ input }) => input.photoUrl)
.caption(({ input }) => `Order ${input.orderId} is packed!`),
invoice: rpc
.whatsapp()
.document()
.input(z.object({ orderId: z.string(), invoiceUrl: z.string() }))
.url(({ input }) => input.invoiceUrl)
.filename(({ input }) => `invoice-${input.orderId}.pdf`),
voiceNote: rpc
.whatsapp()
.audio()
.input(z.object({ audioUrl: z.string() }))
.url(({ input }) => input.audioUrl),
storeLocation: rpc
.whatsapp()
.location()
.input(z.object({ lat: z.number(), lng: z.number() }))
.latitude(({ input }) => input.lat)
.longitude(({ input }) => input.lng)
.name('Pickup Point'),
ackReaction: rpc
.whatsapp()
.reaction()
.input(z.object({ emoji: z.string() }))
.emoji(({ input }) => input.emoji),
feedbackRequest: rpc
.whatsapp()
.interactive()
.input(z.object({ orderId: z.string() }))
.body(({ input }) => `How was order ${input.orderId}?`)
.buttons([
{ id: 'great', title: 'Great!' },
{ id: 'ok', title: 'OK' },
{ id: 'bad', title: 'Not good' },
]),
shareAgent: rpc
.whatsapp()
.contacts()
.input(z.object({ name: z.string(), phone: z.string() }))
.contacts(({ input }) => [
{ name: { formatted: input.name }, phones: [{ phone: input.phone }] },
]),
tutorialClip: rpc
.whatsapp()
.video()
.input(z.object({ videoUrl: z.string() }))
.url(({ input }) => input.videoUrl)
.caption('Watch the setup guide'),
});
const mock = createMockTransport<RenderedWhatsApp>({
name: 'mock-whatsapp',
reply: (rendered) => ({ messageId: `wamid.mock-${rendered.action}` }),
});
const notify = createClient({
catalog,
transportsByChannel: { whatsapp: mock },
});
await notify.orderConfirm.send({
to: '+5511999999999',
input: { orderId: 'ORD-1234', total: 'R$ 199,90' },
});
await notify.feedbackRequest.send({
to: '+5511999999999',
input: { orderId: 'ORD-1234' },
});
await notify.ackReaction.send({
to: '+5511999999999',
messageId: 'wamid.HBgNNTUx...',
input: { emoji: '👍' },
});Recipient addressing
The to field is an opaque string. The channel does not enforce a format. It can be:
- An E.164 phone number (
+5511999999999) - A WhatsApp LID (Meta's new non-phone identifier, currently rolling out)
- A provider-specific identifier (e.g. Baileys internal ID)
Format validation, if needed, is the transport's responsibility.
Transports
The package ships with one transport and reserves room for more:
| Provider | Import | Status |
|---|---|---|
| Meta Cloud API | whatsappMetaTransport from @betternotify/whatsapp/transports | Ready |
| Baileys (unofficial) | n/a | Planned |
| Bird (MessageBird) | n/a | Planned |
Mock transport
Use createMockTransport from core in tests:
import { createMockTransport } from '@betternotify/core/transports';
import type { RenderedWhatsApp } from '@betternotify/whatsapp';
const mock = createMockTransport<RenderedWhatsApp>({
name: 'mock-whatsapp',
reply: (rendered) => ({ messageId: `wamid.mock-${rendered.action}` }),
});
// after sending...
console.log(mock.sent); // [{ rendered: { action: 'text', to: '...', body: '...' }, ctx: { ... } }]Custom transport
Use createTransport to build a transport for any WhatsApp provider:
import { createTransport } from '@betternotify/core/transports';
import type { RenderedWhatsApp } from '@betternotify/whatsapp';
const myTransport = createTransport<RenderedWhatsApp>({
name: 'my-whatsapp',
send: async (rendered) => {
// Call your provider's API with rendered.to, rendered.action, and action-specific fields
return { ok: true, data: { messageId: '...' } };
},
});