# WhatsApp CTA

URL: https://docs.forms.saastro.io/docs/whatsapp
> 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.

# 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.

```tsx
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 delivers | Your server / the Hub submit Worker | The user's own WhatsApp |
| Network call | Yes — a POST you own | **None** |
| Abuse surface | Rate-limit / captcha it | Nothing to flood |
| Lead identity | Whatever the form collects | Comes 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:

```tsx
.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 resolvers** — `utm_*`, `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:

```tsx
.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.

```tsx
.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

```tsx
.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

| Option | Type | Description |
| --- | --- | --- |
| `number` | `string` | **Required.** International prefix, no `+`. Normalized by stripping every non-digit, so `'+34 615 74 57 62'` works. `.build()` throws on fewer than 8 digits. |
| `message` | `string` | Template with `{{field}}` / `{{summary}}`. Defaults to the auto-summary. |
| `reference` | `string` | Attribution 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'`. |
| `label` | `string` | Button text. Defaults to the i18n `whatsappButton`. |
| `hint` | `string \| null` | Helper line under the button. `null` hides it. |
| `variant` | `string` | Variant passed to your injected `Button`. Default `'default'`. |
| `className` | `string` | Extra 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:

```tsx
.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:

```ts
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](/docs/i18n).

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

```json
{
  "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:

```ts

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](/docs/hub-form).
