Skip to content
Get Started

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:

  1. Plain button — one <a> tag, no form.
  2. Progressive form — phone first, then first/last name and submit. Same flow Sendblue uses on its demo page.

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

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.

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:

+1

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.

Terminal window
npm install libphonenumber-js
"use client";
import {
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
} from "react";
import parsePhoneNumberFromString, {
isValidPhoneNumber,
getCountries,
getCountryCallingCode,
formatIncompletePhoneNumber,
type CountryCode,
} from "libphonenumber-js";
const SENDBLUE_NUMBER = "+15551234567"; // your Sendblue number
// ISO 3166-1 alpha-2 → regional-indicator emoji flag
function isoToFlag(iso: string) {
const A = 0x1f1e6 - "A".charCodeAt(0);
return String.fromCodePoint(
A + iso.toUpperCase().charCodeAt(0),
A + iso.toUpperCase().charCodeAt(1),
);
}
const COUNTRIES = getCountries()
.map((iso) => ({
iso,
dial: getCountryCallingCode(iso),
flag: isoToFlag(iso),
}))
.sort((a, b) => a.iso.localeCompare(b.iso));
export function IMessageForm() {
const [country, setCountry] = useState<CountryCode>("US");
const [national, setNational] = useState(""); // digits only, no dial code
const [first, setFirst] = useState("");
const [last, setLast] = useState("");
const [phoneError, setPhoneError] = useState(false);
const prevDigitsLenRef = useRef(0);
const dial = getCountryCallingCode(country);
const digits = national.replace(/\D/g, "");
const e164 = digits ? `+${dial}${digits}` : "";
const phoneValid = e164 ? isValidPhoneNumber(e164) : false;
const revealed = digits.length >= 5;
// Format as the visitor types: "9173599290" → "(917) 359-9290" for US.
const formatted = useMemo(
() => (digits ? formatIncompletePhoneNumber(digits, country) : ""),
[digits, country],
);
function handlePhoneChange(e: ChangeEvent<HTMLInputElement>) {
const v = e.target.value;
setPhoneError(false);
const rawDigits = v.replace(/\D/g, "");
const prev = prevDigitsLenRef.current;
prevDigitsLenRef.current = rawDigits.length;
// Explicit E.164 pasted ("+...") → parse, switch country, strip dial code.
if (v.trim().startsWith("+")) {
const parsed = parsePhoneNumberFromString(v);
if (parsed?.country && parsed.nationalNumber) {
setCountry(parsed.country);
setNational(String(parsed.nationalNumber));
return;
}
}
// Sudden jump in digit count = paste/autofill. Try parsing as E.164
// and strip the dial code if detected. Prefer keeping the user's
// currently-selected country when the digits start with its dial code.
if (rawDigits.length - prev > 1) {
const parsed = parsePhoneNumberFromString("+" + rawDigits);
if (
parsed?.country &&
parsed.nationalNumber &&
rawDigits.length > String(parsed.nationalNumber).length
) {
if (parsed.country !== country && !rawDigits.startsWith(dial)) {
setCountry(parsed.country);
}
setNational(String(parsed.nationalNumber));
return;
}
}
setNational(rawDigits);
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!phoneValid) {
setPhoneError(true);
return;
}
const name = [first, last].filter(Boolean).join(" ").trim() || "there";
const body = `Hi! I'm ${name}, I'd like to learn more about Sendblue.`;
window.location.href = `sms:${SENDBLUE_NUMBER}&body=${encodeURIComponent(body)}`;
}
return (
<form onSubmit={handleSubmit} className="imessage-form" noValidate>
<label className="field">
<span>Your phone number</span>
<div className="phone-field">
<div className="country-pill">
<span>{isoToFlag(country)}</span>
<span>+{dial}</span>
<select
aria-label="Country"
value={country}
onChange={(e) => setCountry(e.target.value as CountryCode)}
>
{COUNTRIES.map((c) => (
<option key={c.iso} value={c.iso}>
{c.flag} {c.iso} +{c.dial}
</option>
))}
</select>
</div>
<input
name="phone"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="(555) 000-0000"
value={formatted}
onChange={handlePhoneChange}
onBlur={() => {
if (digits && !phoneValid) setPhoneError(true);
}}
required
/>
</div>
{phoneError && <span className="error">Invalid phone number</span>}
</label>
<div className={`reveal${revealed ? " is-open" : ""}`}>
<div className="name-row">
<label className="field">
<span>First name</span>
<input
name="given-name"
type="text"
value={first}
onChange={(e) => setFirst(e.target.value)}
autoComplete="given-name"
/>
</label>
<label className="field">
<span>Last name</span>
<input
name="family-name"
type="text"
value={last}
onChange={(e) => setLast(e.target.value)}
autoComplete="family-name"
/>
</label>
</div>
<button type="submit" className="submit-btn" disabled={!phoneValid}>
Open iMessage
</button>
</div>
</form>
);
}
  • 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.
  • formatIncompletePhoneNumber for display. We store raw national digits in state and format them at render time. Pasting +19173599290 parses cleanly; typing 9173599290 formats to (917) 359-9290 as the visitor goes.
  • Progressive reveal without breaking autofill. The name fields and Submit live inside a wrapper that sits at position: absolute; left: -99999px on 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 to position: static when the reveal opens.
.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)}`;
  • Desktop browsers on non-Apple machines won’t do anything useful with sms: links. Detect the platform and hide the button, or fall back to a different channel.
  • 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.