A click-to-text link opens the visitor's messaging app with your number, and
ideally a message, already filled in. mailto: for text.
It is four characters of syntax and about six edge cases. This covers both.
The basic form
<a href="sms:+15551234567">Text us</a>
Tapped on a phone, Messages opens addressed to that number. This form is universally supported and worth using as your baseline.
Two rules for the number itself:
Use E.164. +15551234567, not (555) 123-4567. Formatting characters
produce inconsistent results across platforms, and the leading + with country
code is what makes the link work for someone travelling.
Do not URL-encode the plus. %2B breaks on some Android handsets. Leave it
literal.
Prefilling the message
Here is where it stops being simple.
iOS: sms:+15551234567&body=Hello
Android: sms:+15551234567?body=Hello
iOS has historically used & as the separator; Android uses ?. There is no
single literal that is correct on both.
It gets worse: on iOS the feature is undocumented. Apple's archived URL Scheme Reference says the URL must not contain a body. It has worked for years anyway. That is de-facto behaviour, not a contract, and it is a poor foundation for anything you cannot easily change later.
You will find the hybrid sms:+1555...?&body= recommended widely. It usually
works. It is still a guess about undocumented behaviour on two independently
changing platforms.
The three things that break in production
1. In-app browsers block the handoff. Instagram, X, TikTok and similar apps
render links inside their own webview, and these routinely refuse to hand off to
sms:. The user taps and nothing happens — no error, no app switch. If a
meaningful share of your traffic arrives from social, direct sms: links will
silently fail for those visitors.
2. Long bodies get truncated. Browsers and webviews impose URL length limits, and a long encoded body can be cut before Messages ever receives it. Keep the prefilled text short. A sentence is fine; a paragraph is asking for trouble.
3. Non-ASCII changes the encoding. An emoji or accented character flips an SMS from GSM-7 to UCS-2, which halves the characters per segment and can mangle the message. Keep prefilled openers plain ASCII.
Desktop
sms: on desktop does one of three things: opens a messaging app if one is
registered (macOS Messages, Windows Phone Link), prompts to choose an
application, or does nothing at all.
"Nothing at all" is common, and it is a bad experience on a page where your "Text us" button is the primary call to action. Desktop visitors deserve a fallback that shows the number as selectable text.
The pattern that survives all of this
Do not put an sms: URI in front of users directly. Put an HTTPS URL you control
in front of them, and resolve everything server-side:
https://go.senderz.com/a7k2np
On request, read the User-Agent and redirect appropriately:
iPhone/iPad → sms:+1555...&body=... (302)
Android → sms:+1555...?body=... (302)
anything else → an HTML page with the number and message
What this buys:
The syntax stays changeable. If iOS behaviour shifts, you fix one server function. Links already printed, emailed, or embedded keep working.
Desktop and in-app browsers get a real answer instead of a dead tap.
You can measure it. A direct sms: link is invisible — you learn nothing
about how many people tapped. A redirect gives you the top of the funnel.
Printed media becomes safe. This is the decisive one. A QR code on a receipt cannot be reissued. Encoding a device-specific URI into permanent physical media is a bet you cannot hedge.
Use 302, not 301. A permanent redirect gets cached, which undoes the flexibility that motivated the indirection.
Reference implementation
The redirect logic is small enough to read in full:
function detectPlatform(ua: string | null): 'ios' | 'android' | 'other' {
if (!ua) return 'other'
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios'
if (/Android/i.test(ua)) return 'android'
return 'other'
}
function buildSmsUri(number: string, body: string, platform: string): string {
const encoded = encodeURIComponent(body)
return platform === 'ios'
? `sms:${number}&body=${encoded}`
: `sms:${number}?body=${encoded}`
}
That is the entire platform-specific surface, isolated in one place — which is the point. When it needs to change, there is exactly one thing to edit.
senderZ exposes this as an endpoint so you do not have to host it:
curl -X POST https://api.senderz.com/v1/invites \
-H "Authorization: Bearer $SENDERZ_API_KEY" \
-d '{"label": "Website button", "opener": "Hi!"}'
The returned link is the HTTPS URL. Use it as an href, or encode it as a QR.
FAQ
Questions
Should I use sms: or smsto:?
sms: is the standardised scheme and works on both platforms. smsto: is an older Android-specific variant that is no longer necessary and is not supported on iOS.
Why does my link work on my phone but not my colleague's?
Almost always the iOS/Android separator difference, or an in-app browser blocking the handoff. Test both platforms, and test from inside Instagram rather than only from the native browser.
Can I prefill the message on WhatsApp too?
Yes, but through a different scheme entirely — wa.me links with a text parameter. It does not share syntax with sms:, and a link that does one does not do the other.
Is there any way to know whether the user actually sent it?
Not from the link. You learn that they tapped, and separately that a message arrived. Putting a short reference code in the prefilled body is what lets you connect the two.