Back to blog

developers

Click-to-Text Links: The sms: URL Scheme, Properly Explained

How the sms: scheme works, where iOS and Android diverge, what browsers block it, and how to build a click-to-text link that survives contact with reality.

Noa

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.

Tagged developers sms how-to web

Ready to start sending?

Create your free account and send your first message in minutes.