WhatsApp CTA

Add a "Send via WhatsApp" button next to submit — a client-side channel that opens the user's WhatsApp with a summary of what they filled in, without hitting any endpoint.

View as Markdown

WhatsApp CTA

A configuration-driven “Send via WhatsApp” button that sits next to your submit button. It builds a message from the values the user has already typed and opens wa.me with it prefilled.

FormBuilder.create('contact')
  .addField('name', (f) => f.type('text').label('Name').required())
  .addField('message', (f) => f.type('textarea').label('Message').optional())
  .addStep('main', ['name', 'message'])
  .whatsapp({ number: '+34 615 74 57 62' })
  .build();

That’s the whole setup. With no message template, the button sends an auto-summary of every filled field.


It is not a submit action

This is the one thing to internalize before using it: the WhatsApp CTA is 100% client-side.

submitActions (http/webhook/email)whatsapp
Who deliversYour server / the Hub submit WorkerThe user’s own WhatsApp
Network callYes — a POST you ownNone
Abuse surfaceRate-limit / captcha itNothing to flood
Lead identityWhatever the form collectsComes from the sender’s WhatsApp account

Because no request leaves the page, the CTA never goes through the dispatcher and never appears in your submissions table. A lead that arrives by WhatsApp arrives as a WhatsApp conversation — that’s the point. Name and phone come from the sender’s own account, which is why the pattern works even when the user never fills the contact fields.

Use it alongside a normal submit, not instead of it: fast path for people who prefer chat, form for everyone else.


The message

Auto-summary (default)

Without a message, the CTA sends one line per filled field, Label: value, in declaration order:

Name: Ana
Annual premium: 420
Expiry: in 1 month

Values are humanized, not dumped raw:

  • Fields with options use the option label, not the stored value.
  • Multi-selects join their labels with commas.
  • Dates use the form’s locale.
  • A checked checkbox renders as the localized “Yes”; an unchecked one is simply omitted.
  • Labels are stripped of HTML — WhatsApp is plain text.
  • Repeaters get a header line plus one bullet per item:
Policies:
• Premium: 420 · Expiry: in 1 month
• Premium: 180

Template

Pass message to control the copy. {{fieldName}} interpolates a single value; {{summary}} drops in the auto-summary:

.whatsapp({
  number: '34615745762',
  message: "Hi, I'm {{name}} and I'd like a quote.\n\n{{summary}}",
})

Tokens that don’t match a field are replaced with an empty string, so a stray {{typo}} never reaches the user as literal braces.


What never gets sent

The summary is deliberately conservative. These are excluded with no configuration:

  • Hidden fields and resolversutm_*, clickId, timestamp, landingPage… They also auto-fill on mount, so including them would make enableWhen: 'anyFilled' true before the user typed anything.
  • The consent checkbox — any checkbox/switch carrying mustBeTrue.
  • Empty fields — blank strings, unchecked boxes, empty arrays.
  • file and signature — no useful text representation.
  • Buttons and html content.

For anything else you’d rather keep out of a chat message, opt out per field:

.addField('taxId', (f) => f.type('text').label('Tax ID').whatsapp(false).optional())

Where to put it — and what it costs you

This is the decision that matters, more than any option below. The two placements are not cosmetic: they trade a structured lead against a conversation.

.whatsapp({ number: '34615745762', placement: 'buttons' })  // default
.whatsapp({ number: '34615745762', placement: 'success' })

'buttons' puts the CTA in the last step’s button row, next to submit. WhatsApp then competes with your form: whoever clicks it leaves no structured lead — no row in your backend, no UTM attribution, no consent record. You get a chat in your WhatsApp inbox and nothing in your CRM. That’s the right trade when the business closes by talking (a broker, a local service) and the conversation is the product.

'success' puts it on the success screen, after a successful submit. You keep both: the lead is stored with its attribution and consent, and the conversation opens with the summary already written. For most forms this is the better default even though it isn’t the built-in one — pick it deliberately.

Never give the two buttons equal visual weight. If they look the same, users take the lower-friction path every time, and you lose the structured data without having decided to.

A form whose value is the structured data — a quote calculator, anything with a repeater feeding a downstream process — should not offer WhatsApp at all. A chat destroys the structure you just collected and you end up re-asking everything by hand.


Enabling the button

.whatsapp({ number: '34615745762', enableWhen: 'anyFilled' })  // default
.whatsapp({ number: '34615745762', enableWhen: 'valid' })
  • 'anyFilled' (default) — enabled as soon as one summary-eligible field has a value. This is the quick-path behavior: the user can bail out to WhatsApp after typing a single thing.
  • 'valid' — enabled only when the whole form validates.

enableWhen is ignored with placement: 'success': the form was just submitted, so it was filled and valid by definition.

Careful with enableWhen: 'valid'. It reads react-hook-form’s formState.isValid, which is only kept current when the validation mode is not 'onSubmit' (the default). Pair it with .validation({ mode: 'onTouched' }) or the button stays disabled forever. The library warns in the console (dev only) when it detects this combination.


Options

OptionTypeDescription
numberstringRequired. International prefix, no +. Normalized by stripping every non-digit, so '+34 615 74 57 62' works. .build() throws on fewer than 8 digits.
messagestringTemplate with {{field}} / {{summary}}. Defaults to the auto-summary.
referencestringAttribution line appended to the message. Same {{field}} interpolation, but it can read hidden fields and resolvers. Omitted entirely when none of its tokens resolve.
placement'buttons' | 'success'Where the button renders. Default 'buttons'. See above — this is a real trade-off, not styling.
enableWhen'anyFilled' | 'valid'When the button activates. Default 'anyFilled'. Ignored when placement: 'success'.
labelstringButton text. Defaults to the i18n whatsappButton.
hintstring | nullHelper line under the button. null hides it.
variantstringVariant passed to your injected Button. Default 'default'.
classNamestringExtra classes for the button.

Attribution: knowing where the lead came from

A WhatsApp lead arrives as a chat, so by default you have no idea which campaign produced it. reference closes that gap — it appends a line to the message, and unlike the auto-summary it can read hidden fields and resolvers:

.whatsapp({
  number: '34615745762',
  reference: 'Ref: {{utm_source}}/{{utm_campaign}}',
})
Name: Ana
Message: I'd like a quote

Ref: google/verano26

If none of its tokens resolve — direct traffic, no UTMs — the whole line is dropped, so nobody gets a message ending in Ref: /.


Measuring it

There is one hard limit to accept: you cannot know whether the user actually sent the message. wa.me is a one-way deep link — the page gets no callback, no confirmation, nothing. They may open WhatsApp and abandon the chat.

What you can measure is the click, via the onWhatsappClick plugin hook, fired just before the link opens:

definePlugin({
  name: 'my-analytics',
  version: '1.0.0',
  onWhatsappClick(values, { formId, url, placement }) { /* … */ },
});

The built-in analyticsPlugin implements it as a form_whatsapp_click gtag event carrying form_id and placement. Read it as “opened WhatsApp”, never as a conversion — and keep that distinction when you compare this channel against your form submissions, which are confirmed.


Placement and styling

With the default placement: 'buttons' the CTA renders inside the form’s button row on the last step (where submit lives); with 'success' it renders under the success message. Either way it uses the Button component from your registry, so it inherits your design system with no extra wiring. The wrapper carries data-saastro-whatsapp="buttons" or data-saastro-whatsapp="success" if you need to target it from CSS.


i18n

The button label, the two hint variants and the summary’s “Yes” come from the message catalog (whatsappButton, whatsappHint, whatsappHintDisabled, whatsappYes) — so setDefaultMessages(es) localizes them along with everything else. See i18n.

For per-form copy, the locale overlay carries a whatsapp block:

{
  "i18n": {
    "translations": {
      "es": {
        "whatsapp": {
          "label": "Escríbenos por WhatsApp",
          "message": "Hola, soy {{name}}.\n\n{{summary}}"
        }
      }
    }
  }
}

The number is never translated.


Using the message builder directly

The message construction is pure (no React) and lives in its own lean subpath, so you can reuse the exact same summary outside the form runtime — a floating action button, a bespoke island, a test:

import { buildWhatsappUrl, buildWhatsappSummary, normalizeWhatsappNumber } from '@saastro/forms/whatsapp';

const url = buildWhatsappUrl(config, values, { number: '34615745762' }, 'Yes');

It is deliberately not re-exported from the package root: the CTA is opt-in, and <Form> loads its button in a separate chunk, so forms that don’t configure whatsapp never download any of this.


HubForm

Nothing extra to do: whatsapp travels in the form JSON, so a form authored in the hosted builder renders the CTA wherever <HubForm> renders. See HubForm & Hosted Submit.