WhatsApp link buttons
A link button puts one tappable button under a WhatsApp message that opens a URL in the recipient's browser. Use it when the next step lives on the web, such as a checkout page or a set of workshop dates, rather than in the chat itself. For a choice the recipient answers inside WhatsApp, use reply buttons or list menus instead.
Send a link button
Set interactive.type to cta_url, with a body_text and a cta_url object carrying the button's text and url:
const msg = await bird.whatsapp.send({
to: "+16505551234",
from: "+13124495648",
interactive: {
type: "cta_url",
body_text: "Tap the button below to see the available dates.",
cta_url: { text: "See dates", url: "https://example.com/workshops?click_id=a1b2c3" },
},
});
console.log(msg.id, msg.status);msg = client.whatsapp.send(
to="+16505551234",
from_="+13124495648",
interactive={
"type": "cta_url",
"body_text": "Tap the button below to see the available dates.",
"cta_url": {"text": "See dates", "url": "https://example.com/workshops?click_id=a1b2c3"},
},
)
print(msg.id, msg.status)package main
import (
"context"
"fmt"
"log"
"os"
bird "github.com/messagebird/bird-sdk-go"
"github.com/messagebird/bird-sdk-go/option"
)
func main() {
client, err := bird.NewClient(option.WithAPIKey(os.Getenv("BIRD_API_KEY")))
if err != nil {
log.Fatal(err)
}
msg, err := client.Whatsapp.Send(context.Background(), bird.WhatsappSendParams{
To: "+16505551234",
From: "+13124495648",
Interactive: &bird.WhatsAppInteractiveSend{
Type: "cta_url",
BodyText: "Tap the button below to see the available dates.",
CtaUrl: &bird.WhatsAppInteractiveCtaUrlSend{Text: "See dates", Url: "https://example.com/workshops?click_id=a1b2c3"},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.Id, *msg.Status)
}$interactive = (new WhatsAppMessageSendRequestInteractive())
->setType('cta_url')
->setBodyText('Tap the button below to see the available dates.')
->setCtaUrl(
(new WhatsAppInteractiveSendCtaUrl())
->setText('See dates')
->setUrl('https://example.com/workshops?click_id=a1b2c3'),
);
$message = $bird->whatsapp->send(
to: '+16505551234',
from: '+13124495648',
interactive: $interactive,
);
echo $message->getId(), ' ', $message->getStatus();bird whatsapp send \
--from +13124495648 \
--interactive '{"body_text":"Tap the button below to see the available dates.","cta_url":{"text":"See dates","url":"https://example.com/workshops?click_id=a1b2c3"},"type":"cta_url"}' \
--to +16505551234curl -X POST "https://{region}.platform.bird.com/v1/whatsapp/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": "+16505551234",
"from": "+13124495648",
"interactive": {
"type": "cta_url",
"body_text": "Tap the button below to see the available dates.",
"cta_url": {
"text": "See dates",
"url": "https://example.com/workshops?click_id=a1b2c3"
}
}
}'from is required on every service message: a number your workspace owns, not a Bird-managed one. The full shape adds an optional header, footer, and a quote of an earlier message:
Code example
{
"to": "+16505551234",
"from": "+13124495648",
"in_reply_to_message_id": "wam_01kya19eknftrs2s6p82asmvnh",
"interactive": {
"type": "cta_url",
"header": {
"type": "image",
"url": "https://cdn.example.com/banners/workshop.png"
},
"body_text": "Tap the button below to see the available dates.",
"footer_text": "Dates are subject to change.",
"cta_url": {
"text": "See dates",
"url": "https://example.com/workshops?click_id=a1b2c3"
}
},
"tags": [{ "name": "campaign", "value": "autumn-workshops" }],
"metadata": { "order_id": "A-4192" }
}in_reply_to_message_id quotes an earlier message in the same conversation. See the hub's quoting a message to correlate a reply for how resolution works and what it can miss.
This type sends exactly one cta_url button and cannot carry buttons, list, or cards alongside it. See the hub's buttons section for the shared button shape, which a carousel card's own link button also reuses.
Headers and footers
A header is optional, and it is one of four shapes:
Code example
"header": { "type": "text", "text": "New workshop dates" }
"header": { "type": "image", "url": "https://cdn.example.com/a.png" }
"header": { "type": "video", "url": "https://cdn.example.com/a.mp4" }
"header": { "type": "document", "url": "https://cdn.example.com/a.pdf" }A media header (image, video, or document) carries its file as a public https URL that WhatsApp fetches at send time, rather than an uploaded media handle. footer_text is optional and adds a line below the button.
Limits
| Field | Bound |
|---|---|
| cta_url buttons | exactly one |
| cta_url.text (label) | required, 1 to 20 characters |
| cta_url.url | required, 1 to 2000 characters |
| body_text | required, 1 to 1024 characters |
| footer_text | optional, 1 to 60 characters |
| header.text | 1 to 60 characters |
The 2000-character cap on url is Bird's own: Meta publishes no length limit for this field. url also asserts format: uri, an absolute address with a scheme, but Bird does not check which scheme: an http:// address passes Bird's validation, and Meta is the only judge of whether it delivers.
What a click reports
A tap opens the address in the recipient's browser and nothing comes back to you over the API. A link button's tap is not an interactive_reply: the inbound mapper that produces interactive_reply handles only a reply-button tap and a list-row tap, and a cta_url link has no equivalent inbound shape. What you do see is the ordinary outbound lifecycle, the message's sent, delivered, and read statuses, but read_at tells you the message was opened, not that the button was tapped. There is no click event, no timestamp, and no per-recipient tap signal from WhatsApp or from Bird.
Two ways to get attribution back, since the send itself will not give it to you:
- Instrument the landing page. The only click evidence available is on your own destination server, from the URL you handed out.
- Vary the URL yourself, per recipient. The url you send is a literal string: Bird stores it and passes it to Meta unchanged, with no substitution and no variable syntax. It is identical for every recipient of one send, so per-recipient attribution means generating your own query parameter, such as ?click_id=<value>, and issuing one POST /v1/whatsapp/messages call per recipient. The endpoint already takes a single to per call, so this is bookkeeping on your side rather than a missing API feature.
A third option exists outside this type entirely: a template with a url button variable is personalized per recipient by WhatsApp itself, supplied through the send's button component. That variable must sit at the end of the address, written as {{1}}, so it can vary a trailing path segment or query value but never the host or the middle of the URL. The trade-off: a template buys per-recipient URLs and delivery outside the customer service window, at the cost of Meta's review and a fixed approved shape, where a cta_url send buys free-form, review-free sending inside an open window with a URL you vary yourself.
Limits and edge cases
- The customer service window has to be open. A link button is a service message, deliverable only inside an open window; see the hub's customer service window. The window check fails open, so a 202 is not proof the window was actually open when the send goes out.
- from must be a number your workspace owns. Omitting it, or naming a number that isn't a connected sender, is rejected before the send is created.
- The URL is static for the whole send, and identical for every recipient. There is no per-recipient variable on this type. See What a click reports for how to attribute clicks anyway.
- No tap signal, ever. A link button's tap produces no inbound message and no webhook event. Do not build a feature that promises click metrics from this type alone.
- Bird checks the URL's shape, not its scheme. url must be an absolute address with a scheme, but Bird does not require https, and Meta publishes no scheme restriction either. Contrast a media header's url, which is documented as requiring https.
- A media header URL that WhatsApp can't fetch fails after the send is accepted. WhatsApp fetches the header asset at send time and caches it for 10 minutes; a signed URL has to outlive the send, and an unreachable URL fails asynchronously, with media_rejected on the message's last_error.
None of the shape checks the hub's errors table lists can fire on this type: they inspect a list's rows, a buttons array, or a carousel's cards, and a cta_url message has none of the three. A shape mistake, such as a text label over 20 characters, comes back as a generic request-validation error rather than one of those codes. A quote that doesn't resolve fails the request before anything is created or charged: 404 E15071 when the id names no message this workspace holds, 422 E15072 when it names one that cannot be quoted. For the errors any WhatsApp send can hit, a closed window, a missing or invalid sender, or an invalid recipient, see the hub's errors and Sending WhatsApp messages.
Next steps
- WhatsApp interactive messages: what all six interactive types share
- WhatsApp templates: for a url button variable that WhatsApp personalizes per recipient
- Sending WhatsApp messages: the request envelope, the 202 model, and safe retries
Related resources
Continue with the documentation, guides and examples for this topic. Resources are in English.
Watch the guideConnecting WhatsApp to Bird: from buying a number to a live channelUnderstand the conceptWhat is the 24-hour customer service window on WhatsApp?Use the toolWhatsApp message builderExplore the capabilityWhatsApp
Try the practice and get an implementation brief