Open iMessage from your website
Add a button that opens iMessage with a pre-filled number and message | iMessage for Business
An sms: link opens the visitor’s Messages app with the phone number and message already filled in. On an iPhone or Mac texting an iMessage-enabled number, the thread opens blue. This guide gives you two drop-in patterns:
- Plain button — one
<a>tag, no form. - Progressive form — phone first, then first/last name and submit. Same flow Sendblue uses on its demo page.
How the sms: URL works
Section titled “How the sms: URL works”sms:+15551234567&body=Hello%20from%20Sendblue
(Click it — on an Apple device this opens Messages with both fields filled in.)
- The number must be in E.164 format (the
+is an international-dialing prefix that virtually every API, including Sendblue, requires). - The body must be URL-encoded — use
encodeURIComponent()in JavaScript. - Separator quirk. RFC 5724 says the separator should be
?body=, and Android follows it. iOS has always used&body=instead. The hybrid?&body=is a community workaround that reportedly works on both, but no spec defines it — for iPhone visitors, use&body=.
Version 1: Plain button
Section titled “Version 1: Plain button”Live demo — click to open Messages with the number and body prefilled:
Drop this into any HTML page. Replace the number and message with your own.
<a href="sms:+15551234567&body=Hi%21%20I%27d%20like%20to%20learn%20more%20about%20Sendblue." class="imessage-button"> Message us on iMessage</a>
<style> .imessage-button { display: inline-flex; align-items: center; padding: 12px 24px; background: #3b82f6; color: white; border-radius: 9999px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-weight: 500; text-decoration: none; } .imessage-button:hover { background: #2563eb; }</style>On iOS and macOS, Messages opens with your number and message prefilled. On Android, the default SMS app opens.
Handle unsupported browsers
Section titled “Handle unsupported browsers”Some browsers cannot open sms: links. Desktop Windows, Linux, kiosk browsers, in-app IDE browsers, and some embedded WebViews may have no text handler. Social app browsers can be worse: the device can text, but the WebView may not hand off to Messages.
Use one CTA that adapts:
- Normal browsers: tap “Text us” and try the normal
sms:link. - Detected iOS app browsers: show “[app name] may block texting. Tap to copy link”; on tap, copy the page URL and point the user to Safari.
- Detected Android app browsers: same flow, but point the user to Chrome.
- Other failed handoffs: if the page never appears to leave after the click, copy the number and starter text. If copying fails, show the number in the CTA.
There is no stable web API that forces an arbitrary social WebView to reopen your page in Safari. Android has intent: URLs, but social WebViews vary, so copying the page URL is the safer fallback.
Live demo — in an X/Twitter, Instagram, TikTok, etc. browser, the button asks the user to copy the page link. In a normal browser, it says “Text us” and falls back if Messages does not open.
The code below wraps the detection and fallback behavior in one helper, createTextMessageButton(), so the button only has to render a state and call open().
<button id="text-us-button" type="button" aria-live="polite" aria-atomic="true">Text us</button>
<script>
function createTextMessageButton({
number,
message,
pageUrl = window.location.href,
timeoutMs = 1500,
onStateChange = () => {},
}) {
let state = { kind: "ready", label: "Text us" };
function setState(nextState) {
state = nextState;
onStateChange(nextState);
}
function getPlatform() {
const ua = navigator.userAgent || "";
const isiPadOS = /Macintosh/i.test(ua) && navigator.maxTouchPoints > 1;
if (/iPhone|iPad|iPod/i.test(ua) || isiPadOS) return "ios";
if (/Android/i.test(ua)) return "android";
return "other";
}
function getInAppBrowser() {
const ua = navigator.userAgent || "";
const matches = [
[/Twitter|XTwitter/i, "X"],
[/FBAN\/Messenger|FB_IAB\/Messenger|MessengerForiOS/i, "Messenger"],
[/FBAN|FBAV|FBIOS|FB_IAB|FB4A/i, "Facebook"],
[/Instagram/i, "Instagram"],
[/TikTok|musical_ly|BytedanceWebview|ByteLocale|trill_/i, "TikTok"],
[/LinkedInApp/i, "LinkedIn"],
[/WhatsApp/i, "WhatsApp"],
[/Line\//i, "LINE"],
];
const match = matches.find(([pattern]) => pattern.test(ua));
return match ? match[1] : null;
}
async function copyText(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {}
let textarea;
try {
textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
return Boolean(document.execCommand && document.execCommand("copy"));
} catch {
return false;
} finally {
if (textarea) textarea.remove();
}
}
function watchForAppHandoff() {
let didLeavePage = false;
const markLeftPage = () => {
didLeavePage = true;
};
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") markLeftPage();
};
window.addEventListener("pagehide", markLeftPage, { once: true });
window.addEventListener("blur", markLeftPage, { once: true });
document.addEventListener("visibilitychange", handleVisibilityChange);
return {
didLeave: () => didLeavePage,
cleanup: () => {
window.removeEventListener("pagehide", markLeftPage);
window.removeEventListener("blur", markLeftPage);
document.removeEventListener("visibilitychange", handleVisibilityChange);
},
};
}
const platform = getPlatform();
const bodySeparator = platform === "android" ? "?body=" : "&body=";
const smsHref = "sms:" + number + bodySeparator + encodeURIComponent(message);
const appBrowser = platform === "ios" || platform === "android" ? getInAppBrowser() : null;
if (appBrowser) {
setState({
kind: "blocked",
appBrowser,
label: appBrowser + " may block texting. Tap to copy link",
});
} else {
setState(state);
}
async function open() {
if (state.kind === "blocked") {
const copied = await copyText(pageUrl);
const browserName = platform === "ios" ? "Safari" : "Chrome";
setState({
...state,
label: copied
? "Page copied. Open in " + browserName + " to text us"
: "Copy failed. Open this page in " + browserName,
});
return;
}
const handoff = watchForAppHandoff();
window.location.href = smsHref;
window.setTimeout(async () => {
handoff.cleanup();
if (!handoff.didLeave() && document.visibilityState === "visible") {
const copied = await copyText(number + "\n" + message);
setState({
kind: "failed",
label: copied
? "Browser could not open Messages"
: "Copy failed. Text " + number,
});
}
}, timeoutMs);
}
return {
open,
smsHref,
getState: () => state,
};
}
const button = document.querySelector("#text-us-button");
const textUs = createTextMessageButton({
number: "+15551234567",
message: "Hi! I'd like to learn more about Sendblue.",
onStateChange(state) {
button.textContent = state.label;
button.style.background = state.kind === "ready" ? "#007aff" : "#dc2626";
},
});
button.addEventListener("click", textUs.open);
</script> Version 2: Progressive form
Section titled “Version 2: Progressive form”Ask for a phone number first. Once the visitor has typed a few digits, reveal the name fields and a Submit button — then validate the full number on submit before opening iMessage with a personalized message.
One nice thing about the form is that it gets browser autofill for free. The autocomplete="tel", "given-name", and "family-name" attributes let Chrome, Safari, and most mobile browsers fill everything with a single tap from the visitor’s saved contact info. The prefilled iMessage body can then be personalized (“Hi, I’m Ada…”) using what they entered.
Live demo — type a phone number (at least 5 digits) to reveal the name fields and submit button:
The code below uses libphonenumber-js for parsing, validation, and as-you-type formatting — no phone-input wrapper component. The phone field is a plain <input type="tel" autoComplete="tel"> paired with a native <select> for the country. That keeps Chrome’s autofill working perfectly (it only reliably offers contact-profile autofill on stock inputs with the right autocomplete attributes) and keeps the dep count to one.
Install dependencies
Section titled “Install dependencies”npm install libphonenumber-jsComponent
Section titled “Component”<form id="imessage-form" novalidate>
<label>
<span>Your phone number</span>
<input id="phone" name="phone" type="tel" autocomplete="tel" />
</label>
<div id="name-fields" hidden>
<input name="given-name" autocomplete="given-name" placeholder="First name" />
<input name="family-name" autocomplete="family-name" placeholder="Last name" />
<button type="submit">Open iMessage</button>
</div>
</form>
<script type="module">
import {
isValidPhoneNumber,
formatIncompletePhoneNumber,
} from "https://esm.sh/libphonenumber-js";
const SENDBLUE_NUMBER = "+15551234567";
const form = document.querySelector("#imessage-form");
const phone = form.querySelector("#phone");
const nameFields = form.querySelector("#name-fields");
phone.addEventListener("input", () => {
const digits = phone.value.replace(/\D/g, "");
phone.value = formatIncompletePhoneNumber(digits, "US");
nameFields.hidden = digits.length < 5;
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const digits = phone.value.replace(/\D/g, "");
const e164 = `+1${digits}`;
if (!isValidPhoneNumber(e164)) return;
const first = form.elements["given-name"].value;
const last = form.elements["family-name"].value;
const name = [first, last].filter(Boolean).join(" ") || "there";
const body = `Hi! I'm ${name}, I'd like to learn more about Sendblue.`;
window.location.href = `sms:${SENDBLUE_NUMBER}&body=${encodeURIComponent(body)}`;
});
</script> Why it’s structured this way
Section titled “Why it’s structured this way”- Plain
<input type="tel" autoComplete="tel">. The phone input has no library wrapper, which is what Chrome’s profile-autofill scanner expects — clicking it offers the whole contact (phone + first + last) in one tap. formatIncompletePhoneNumberfor display. We store raw national digits in state and format them at render time. Pasting+19173599290parses cleanly; typing9173599290formats to(917) 359-9290as the visitor goes.- Progressive reveal without breaking autofill. The name fields and Submit live inside a wrapper that sits at
position: absolute; left: -99999pxon first load — still rendered with real dimensions, so Chrome indexes them for autofill, but visually hidden until the visitor has typed a few digits. The wrapper flips toposition: staticwhen the reveal opens.
Minimal styles
Section titled “Minimal styles”.imessage-form { display: flex; flex-direction: column; gap: 16px; max-width: 460px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;}.field { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: #374151;}.phone-field { display: flex; align-items: stretch; height: 40px; border: 1px solid #d1d5db; border-radius: 12px; overflow: hidden;}.phone-field:focus-within { border-color: #007aff; box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.15);}.country-pill { position: relative; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border-right: 1px solid #d1d5db; cursor: pointer; user-select: none;}.country-pill select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; appearance: none; -webkit-appearance: none; border: 0;}.phone-field input { flex: 1; min-width: 0; padding: 0 14px; border: 0; background: transparent; font-size: 16px; outline: none;}.field input[type="text"] { height: 40px; padding: 0 14px; border: 1px solid #d1d5db; border-radius: 12px; font-size: 16px;}.name-row { display: flex; gap: 12px;}.name-row .field { flex: 1;}.error { color: #dc2626; font-size: 13px;}/* Keep the name fields rendered at real dimensions off-screen so browser autofill still indexes them, even before the progressive reveal opens. */.reveal { position: absolute; left: -99999px; opacity: 0; pointer-events: none; display: flex; flex-direction: column; gap: 16px; transition: opacity 0.22s ease;}.reveal.is-open { position: static; opacity: 1; pointer-events: auto;}.submit-btn { height: 40px; padding: 0 24px; background: linear-gradient(180deg, #34aadc 0%, #007aff 100%); color: white; border: 0; border-radius: 12px; font-size: 15px; font-weight: 600; cursor: pointer;}.submit-btn:disabled { opacity: 0.5; cursor: not-allowed;}Optional: capture the contact before opening iMessage
Section titled “Optional: capture the contact before opening iMessage”In handleSubmit, POST the phone and name to your backend or CRM before setting window.location.href. That way you keep the contact’s data even if the visitor never hits send in Messages.
await fetch("/api/contacts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone, firstName, lastName }),});window.location.href = `sms:${SENDBLUE_NUMBER}&body=${encodeURIComponent(body)}`;Gotchas
Section titled “Gotchas”- Desktop browsers on non-Apple machines usually won’t do anything useful with
sms:links. You can detect the platform ahead of time, or try the link first and show a fallback if the browser never appears to leave the page. - In-app browsers may block app handoff. X/Twitter, Instagram, and other social-app WebViews can prevent
sms:links from opening Messages. Detect the WebView when you can, but always provide a copy fallback. - Query-parameter separator. See the RFC vs. iOS quirk above — if you want one link to work everywhere,
?&body=is the workaround most sites use, but it isn’t standardized. - Keep the prefilled body short. Browsers and mobile WebViews enforce URL-length limits (often a few thousand characters), and very long encoded URLs can fail or be truncated before they ever reach the Messages app. A short, friendly opener is more reliable than a paragraph.