WhatsApp

Message templates

Author WhatsApp message templates in TSX, publish them, submit them to Meta for review, and send them once approved.

Outside a recipient's 24-hour service window, every WhatsApp message must be a Meta-approved template: fixed text with placeholders you fill at send time. A WhatsApp template in Samva is a template like any other. You write it in TSX, publish it, then submit it to Meta for review. Once Meta approves it, you send it by its handle with the values its placeholders need.

Write a template

A WhatsApp template declares its input schema, at least one fixture, and a whatsapp message built from Meta's template anatomy: a body, and optionally a header, a footer, and buttons. Start from one of the WhatsApp starters on the dashboard's Templates page, or write the entry yourself:

templates/order-update.tsx
/** @jsxImportSource @samva/markup/email */
import { defineTemplate } from "@samva/markup";
import { jsonSchema } from "@samva/markup/input-schema";
import { WhatsApp } from "@samva/markup/whatsapp";

export default defineTemplate({
  id: "order-update",
  schema: jsonSchema<{ firstName: string; orderNumber: string }>({
    type: "object",
    properties: { firstName: { type: "string" }, orderNumber: { type: "string" } },
    required: ["firstName", "orderNumber"],
    additionalProperties: false,
  }),
  fixtures: { default: { firstName: "Maya", orderNumber: "NW-10482" } },
  whatsapp: (input) => (
    <WhatsApp language="en_US" category="utility">
      <WhatsApp.Body>
        {"Hi "}
        {input.firstName}
        {", your order "}
        {input.orderNumber}
        {" has shipped."}
      </WhatsApp.Body>
      <WhatsApp.Footer>{"Reply STOP to unsubscribe"}</WhatsApp.Footer>
      <WhatsApp.Buttons>
        <WhatsApp.UrlButton url={`https://example.com/track/${input.orderNumber}`}>
          {"Track order"}
        </WhatsApp.UrlButton>
      </WhatsApp.Buttons>
    </WhatsApp>
  ),
});

Each value you read from input becomes a numbered placeholder in the template Meta reviews, in the order it first appears: the body above registers as Hi {{1}}, your order {{2}} has shipped. The template's first fixture supplies the example values Meta asks for.

Meta approves the text, so the shape of the message is fixed:

  • Text parts hold literal text and values only, with no conditions or loops.
  • The body begins and ends with fixed words, not a value. Punctuation around a value does not count: Hello {{1}}. needs a closing phrase, such as Hello {{1}}. Your order has shipped.
  • A header carries at most one value; a footer carries none.
  • Buttons are fixed: no button that appears on a condition, and labels are literal text.
  • A URL button's address starts with a fixed https URL and may vary only at its end.
  • A template has at most one flow button, which opens a flow.
  • Headers are text. Media headers and authentication templates are not supported.

Publishing refuses a template that breaks one of these rules and lists every reason.

Open a flow from a button

WhatsApp.FlowButton is a button that opens one of your WhatsApp flows when the customer taps it. This is how a flow opens outside the 24-hour service window, because a template can start a conversation and a flow message cannot.

<WhatsApp.Buttons>
  <WhatsApp.FlowButton flow="appointment" screen="DETAILS">
    {"Book now"}
  </WhatsApp.FlowButton>
</WhatsApp.Buttons>
PropMeaning
flowThe flow's handle, such as appointment for flows/appointment.json. Lowercase letters, numbers, and hyphens.
actionOptional. navigate (the default) opens a screen of the flow; data_exchange asks the flow's endpoint for the first screen.
screenOptional. The screen a navigate button opens on, instead of the flow's first entry screen. Refused with data_exchange.
childrenThe button label, fixed text.

The template names the flow by its handle; Samva swaps in the flow's id on Meta when it submits the template, because Meta numbers the same flow differently on each business account. So the flow must be registered on that account before you submit the template: create the flow, publish a version, and register it (see WhatsApp flows). A submission is refused with a validation error that says what is missing when the handle names no flow of yours, or when the flow has no published or throttled registration on the template's business account.

The template opens the flow version it was submitted with. Publishing and registering a newer version of the flow does not change a template Meta has already approved; submit the template again to open the newest published version.

Name, language, and category

Meta's name for the template comes from its handle, with hyphens as underscores: the handle order-update registers as order_update. Set language to a Meta language code such as en_US; a template has one language. A business account holds one template per name and language, so submitting a second template with the same name and language is refused; give it its own handle.

category is the category you declare, utility or marketing:

CategoryUse for
utilityOrder updates, receipts, account notifications.
marketingPromotions, offers, announcements.

Meta can assign a different category when it reviews the template. A send is billed on the category Meta assigns.

To change the category of a rejected template, publish the new category and submit again. Meta does not change the category of an approved template, so that submission is refused; change the handle to register the content as a new template under the new category.

Submit for review

Publishing a template does not send it to Meta. Submit it when you're ready: from the Message templates section of the dashboard's WhatsApp page, or with the API:

curl -X POST https://api.samva.dev/v1/templates/tmpl_7q2xk9mvt4znw8rh/registrations \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "provider": "whatsapp" }'

The template registers on the business account of your default number. Pass businessAccountId to register it on another connected number's account. The response is the registration:

{
  "id": "treg_3xk9mvt4znw8rh7q",
  "templateId": "tmpl_7q2xk9mvt4znw8rh",
  "publicationId": "tpub_k9mvt4znw8rh7q2x",
  "target": { "provider": "whatsapp", "wabaId": "102290129340398" },
  "name": "order_update",
  "language": "en_US",
  "status": "submitted",
  "category": "utility",
  "providerCategory": "utility",
  "providerTemplateId": "1689556908129832",
  "reason": null,
  "submittedAt": "2026-10-13T10:30:00.000Z",
  "statusChangedAt": "2026-10-13T10:30:00.000Z",
  "createdAt": "2026-10-13T10:30:00.000Z",
  "updatedAt": "2026-10-13T10:30:00.000Z"
}

Read a template's registrations with GET /v1/templates/{id}/registrations, or one with GET /v1/templates/{id}/registrations/{registrationId}.

Review status

Meta reviews the template, and the registration's status follows its decision:

StatusMeaning
draftRecorded, but Meta has not accepted the submission yet. Submit again.
submittedMeta is reviewing it. Wait for the decision.
approvedMeta approved it. It sends.
rejectedMeta rejected it; reason says why. Fix the template and submit again.
pausedMeta paused it after low-quality feedback; reason says why.
disabledMeta disabled it. It no longer sends and cannot be resubmitted.

Each change sends a template.registration.updated webhook event with the registration and the status it moved from.

Change an approved template

Publish the change, then submit again. Samva sends the new content to the same Meta template, and Meta reviews it again. Until Meta approves the new content, the template does not send, so a send never fills new placeholders into old approved text. A message accepted before the resubmission fails instead of sending its values into the new text; send it again once Meta approves.

Send a template

Reference the template by its handle or id and pass the values its schema declares; see Send a message. Samva checks the values against the template's schema and fills each placeholder the way the template renders it. A template with no approved registration on the sending number's business account is refused with a conflict error.

A template with a flow button takes an optional flowToken beside templateData. The flow returns it unchanged as flowToken on the customer's answer and on every request to the flow's endpoint. Without one, the button carries the message's id; a broadcast or a journey step gives each contact's message its own id, so the token tells you whose flow it is.

samva whatsapp send --to +16175559876 --template appointment-invite --flow-token booking-4821
{
  "channel": "whatsapp",
  "to": [{ "phone": "+16175559876" }],
  "whatsapp": { "templateSlug": "appointment-invite", "flowToken": "booking-4821" }
}

Related documentation

On this page