WhatsApp

Interactive and media messages

Send media, reply buttons, lists, links, reactions, locations, and contacts on WhatsApp, and receive the customer's replies.

Beyond text and templates, the unified messages API sends richer WhatsApp content. Every content type below goes in the whatsapp object of a channel: "whatsapp" send, and a send carries exactly one of them. They are free-form content, so each one needs an open 24-hour service window; outside it the send is refused with a validation error telling you to use an approved template. Samva never converts a refused send into a template for you.

A payload that breaks a limit on this page is a 400 validation error that names the field.

Send media

An image, video, audio file, document, or sticker goes in media. Name its source with either url, a public http or https link that WhatsApp fetches, or mediaId, a file you uploaded to Samva.

curl -X POST https://api.samva.dev/v1/messages \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp",
    "to": [{ "phone": "+16175559876" }],
    "whatsapp": {
      "media": {
        "type": "document",
        "url": "https://example.com/receipt.pdf",
        "filename": "receipt.pdf",
        "caption": "Your receipt"
      }
    }
  }'

caption works on images, videos, and documents (up to 1,024 characters). filename names a document. Audio and stickers carry neither.

WhatsApp accepts these formats and sizes:

TypeFormatsLargest file
imageJPEG, PNG5 MB
videoMP4, 3GPP16 MB
audioAAC, AMR, MP3, M4A, OGG16 MB
documentPDF, plain text, Word, Excel, and PowerPoint (legacy and Open XML)100 MB
stickerWebP500 KB

Upload a file instead of linking it

When the file is not publicly reachable, upload it as media with the whatsapp_media purpose and send its mediaId. Samva delivers the bytes to WhatsApp for you. An uploaded file is at most 16 MB, so link a larger document by url. A file whose format or size WhatsApp would refuse is rejected when you send.

# 1. Create the media object; the response carries its id and the upload request to make.
curl -X POST https://api.samva.dev/v1/media \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "filename": "menu.pdf", "contentType": "application/pdf", "sizeBytes": 48213, "purpose": "whatsapp_media" }'

# 2. PUT the bytes to upload.url with every header in upload.headers, then complete the upload.
curl -X POST https://api.samva.dev/v1/media/media_k9mvt4znw8rh7q2x/complete \
  -H "X-API-Key: samva_sk_live_your_api_key"

# 3. Send it by id.
curl -X POST https://api.samva.dev/v1/messages \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp",
    "to": [{ "phone": "+16175559876" }],
    "whatsapp": { "media": { "type": "document", "mediaId": "media_k9mvt4znw8rh7q2x", "filename": "menu.pdf" } }
  }'

Send reply buttons

Reply buttons let the customer answer with one tap. A message has a body (up to 1,024 characters) and one to three buttons. Each button has an id (up to 256 characters) that comes back to you when the customer taps it, and a title (up to 20 characters) that they see. Ids and titles are distinct within a message.

Add an optional footer (up to 60 characters) and an optional header: { "type": "text", "text": "…" } (up to 60 characters), or an image, video, or document given by url or mediaId.

curl -X POST https://api.samva.dev/v1/messages \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp",
    "to": [{ "phone": "+16175559876" }],
    "whatsapp": {
      "interactive": {
        "type": "buttons",
        "body": "Your order ships today. Should we leave it at the door?",
        "footer": "Reply anytime",
        "buttons": [
          { "id": "door", "title": "Leave at door" },
          { "id": "hold", "title": "Hold for pickup" }
        ]
      }
    }
  }'

Send a list

A list shows a button that opens a menu of choices. Use it when there are more than three.

curl -X POST https://api.samva.dev/v1/messages \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp",
    "to": [{ "phone": "+16175559876" }],
    "whatsapp": {
      "interactive": {
        "type": "list",
        "body": "When should we deliver?",
        "button": "Pick a slot",
        "sections": [
          {
            "title": "Tomorrow",
            "rows": [
              {
                "id": "tue-am",
                "title": "Morning",
                "description": "8am to 12pm"
              },
              {
                "id": "tue-pm",
                "title": "Afternoon",
                "description": "12pm to 5pm"
              }
            ]
          },
          {
            "title": "Wednesday",
            "rows": [
              {
                "id": "wed-am",
                "title": "Morning"
              }
            ]
          }
        ]
      }
    }
  }'
FieldLimit
bodyUp to 4,096 characters
buttonUp to 20 characters
headerText only, up to 60 characters
sections1 to 10; every section needs a title when there are several
Section titleUp to 24 characters
RowsAt most 10 across all sections, with distinct ids
Row idUp to 200 characters
Row titleUp to 24 characters
Row descriptionOptional, up to 72 characters

A call-to-action message shows a button that opens a URL. It takes the same body, header, and footer as reply buttons, plus a displayText for the button (up to 20 characters) and an http or https url:

curl -X POST https://api.samva.dev/v1/messages \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp",
    "to": [{ "phone": "+16175559876" }],
    "whatsapp": {
      "interactive": {
        "type": "cta_url",
        "body": "Track your order any time.",
        "displayText": "Track order",
        "url": "https://example.com/orders/ORD-12345"
      }
    }
  }'

Send a flow

A flow message opens a multi-screen form inside WhatsApp. It is an interactive message of type flow that names a registered flow and the button that opens it. Authoring, registering, and sending flows are covered in WhatsApp flows. To open a flow outside the service window, use a template's flow button.

React to a message

A reaction puts one emoji on a message in the same chat: one the customer sent you, or one you sent them that reached WhatsApp. Pass its Samva message id and exactly one emoji.

curl -X POST https://api.samva.dev/v1/messages \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp",
    "to": [{ "phone": "+16175559876" }],
    "whatsapp": {
      "reaction": {
        "messageId": "msg_01j8k3m5n7p9q2r4s6tv",
        "emoji": "👍"
      }
    }
  }'

A reaction cannot target another reaction, or a message from a different chat.

Share a location

curl -X POST https://api.samva.dev/v1/messages \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp",
    "to": [{ "phone": "+16175559876" }],
    "whatsapp": {
      "location": {
        "latitude": 37.7749,
        "longitude": -122.4194,
        "name": "Samva Pickup Point",
        "address": "1 Market St, San Francisco, CA"
      }
    }
  }'

latitude is between -90 and 90 and longitude between -180 and 180. name and address are optional.

Share contacts

Send one to 257 contact cards. Each card needs name.formattedName; everything else is optional. Give a phone's waId and the card offers to message that contact on WhatsApp.

curl -X POST https://api.samva.dev/v1/messages \
  -H "X-API-Key: samva_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp",
    "to": [{ "phone": "+16175559876" }],
    "whatsapp": {
      "contacts": [
        {
          "name": {
            "formattedName": "Ada Lovelace",
            "firstName": "Ada",
            "lastName": "Lovelace"
          },
          "phones": [
            {
              "phone": "+16175550100",
              "type": "WORK",
              "waId": "16175550100"
            }
          ],
          "emails": [
            {
              "email": "ada@example.com",
              "type": "WORK"
            }
          ],
          "org": {
            "company": "Analytical Engines",
            "title": "Founder"
          },
          "birthday": "1815-12-10"
        }
      ]
    }
  }'

A card can also carry urls, addresses, and the remaining name parts (middleName, prefix, suffix). birthday is YYYY-MM-DD.

Send from the CLI

samva whatsapp send covers the common cases with flags and takes any content type as JSON:

# Media by link
samva whatsapp send --to +16175559876 \
  --media-url https://example.com/receipt.pdf --media-type document --caption "Your receipt"

# Reply buttons
samva whatsapp send --to +16175559876 \
  --body "Should we leave it at the door?" --button "door:Leave at door" --button "hold:Hold for pickup"

# Reaction
samva whatsapp send --to +16175559876 --react msg_01j8k3m5n7p9q2r4s6tv --emoji 👍

# Anything else: the whatsapp object as JSON, inline, from a file, or on stdin
samva whatsapp send --to +16175559876 --content @list.json
cat location.json | samva whatsapp send --to +16175559876 --content -

Each call names exactly one content source, and --idempotency-key makes a retry safe.

Receive replies

When a customer taps a button, picks a list row, submits a flow, reacts, shares a location or contacts, or sends media, Samva publishes a message.received webhook with channel: "whatsapp". Subscribe an endpoint to message.received and the whatsapp channel; the event catalog describes delivery and signing.

body carries the message text, or the caption of a media message, and is absent when there is neither. messageType names what arrived, and whatsapp carries the content. A tap on a reply button looks like this:

{
  "type": "message.received",
  "timestamp": "2026-01-15T10:05:33.900Z",
  "data": {
    "messageId": "msg_01j8k4n6p8q2r4s6t9vw",
    "channel": "whatsapp",
    "status": "delivered",
    "direction": "inbound",
    "from": "+16175559876",
    "to": "+16175550123",
    "messageType": "interactive",
    "providerMessageId": "wamid.HBgLMTYxNzU1NTk4NzYVAgASGBQz",
    "whatsapp": {
      "reply": { "type": "button", "id": "door", "title": "Leave at door" },
      "context": {
        "messageId": "msg_01j8k3m5n7p9q2r4s6tv",
        "providerMessageId": "wamid.HBgLMTYxNzU1NTAxMjMVAgARGBI2"
      }
    }
  }
}

The whatsapp object holds whichever of these the message carries:

FieldContents
replytype (button, list, or template_button), id, title, and for a list row its description.
flowResponseresponse (the payload the flow's complete action sent), flowToken (the token the flow message carried), and, when present, body. See WhatsApp flows.
mediatype, providerMediaId, and, when present, mimeType, caption, and filename.
reactionmessageId (the message reacted to; null when it is not one Samva holds), providerMessageId, and emoji (null when the customer removed the reaction).
locationlatitude, longitude, and, when present, name, address, and url.
contactsThe shared contact cards, in the same shape you send.
contextThe message this one answers: messageId and providerMessageId.

Read data by type, and ignore fields you do not recognize.

Match a reply to the message it answers

Two fields tie a reply back to what you sent:

  • whatsapp.context.messageId is the Samva id of the message the customer answered. It is the id that the send returned, so you can look up which order, ticket, or conversation step that message belonged to. It is null when the message was not sent through Samva. It can also be null when the answer arrives before Samva records that Meta accepted the send. The stored message gets the id once Samva records it, so GET /v1/messages/{id}/content returns it.
  • whatsapp.reply.id is the id you gave the button or list row, returned exactly as you sent it. Put whatever your handler needs to route the answer in it, such as door above or reschedule:ORD-12345.
handle-whatsapp-reply.ts
import { isKnownEvent, type WebhookEvent } from "@samva/webhooks";

export async function handleEvent(event: WebhookEvent) {
  if (!isKnownEvent(event) || event.type !== "message.received") return;
  if (event.data.channel !== "whatsapp") return;

  const reply = event.data.whatsapp?.reply;
  const answered = event.data.whatsapp?.context?.messageId;
  if (reply === undefined || answered == null) return;

  // Load the order you stored against this outbound message id when you sent it.
  const order = await orders.findByMessageId(answered);
  if (reply.id === "door") await orders.setDelivery(order.id, "door");
  if (reply.id === "hold") await orders.setDelivery(order.id, "hold");
}

A template's quick-reply button arrives as reply.type: "template_button", and its id is the button's payload. A customer can also reply to a message by quoting it, which sets context without a reply.

GET /v1/messages/{id}/content returns the same whatsapp content for a stored message.

Related documentation

On this page