WhatsApp
Channels

WhatsApp

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/whatsapp

Setup

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 template

Each 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:

ProviderImportStatus
Meta Cloud APIwhatsappMetaTransport from @betternotify/whatsapp/transportsReady
Baileys (unofficial)n/aPlanned
Bird (MessageBird)n/aPlanned

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: '...' } };
  },
});

On this page