Constructing WhatsApp and Telegram Share Intent URLs

This guide is part of Building a Custom Share Sheet, which itself is part of Permission Flows and Progressive Enhancement.

Messaging apps are where most social sharing actually happens, and reaching them needs no SDK — both WhatsApp and Telegram accept a plain HTTPS URL with query parameters. The subtlety is that the two services model a message differently: WhatsApp takes one combined text field, Telegram takes the link and the caption separately. Get that wrong and the shared message either loses its URL or shows it twice.

Feature Detection Gate

Intent links are the fallback path, so the only question is whether the native sheet should have handled this instead.

// prefer-native.js
export function shouldUseIntentLinks(payload) {
  if (window.isSecureContext !== true) return true;
  if (typeof navigator.share !== 'function') return true;
  if (payload?.files?.length) {
    // Intent URLs cannot carry files at all — only the native sheet can.
    return typeof navigator.canShare === 'function' && !navigator.canShare(payload);
  }
  return false;
}

Where the native sheet exists, use it: it reaches every installed app rather than the two you hard-coded, as choosing between native share and a custom sheet argues in full.

Step 1 — Compose Per Service, Then Encode

// messaging-intents.js
export function whatsAppUrl({ title = '', text = '', url }) {
  // WhatsApp has ONE field: everything must be composed into it first.
  const message = [title, text, url].filter(Boolean).join('\n');
  return `https://wa.me/?text=${encodeURIComponent(message)}`;
}

export function telegramUrl({ title = '', text = '', url }) {
  // Telegram keeps them separate and renders a preview from `url`.
  const caption = [title, text].filter(Boolean).join(' — ');
  return `https://t.me/share/url?url=${encodeURIComponent(url)}` +
         `&text=${encodeURIComponent(caption)}`;
}

Composing before encoding, rather than encoding the pieces and concatenating them, is the ordering that keeps newlines intact — encodeURIComponent turns \n into %0A, which WhatsApp renders as a real line break. Encode first and join with a literal newline and the newline itself is never escaped, which truncates the message on some clients.

Note also that neither service should receive the URL twice. On Telegram, putting the link in text as well as url produces a message with the address printed above its own preview card.

Two Message Models The same title, note and URL are mapped differently per service. WhatsApp receives one combined text parameter containing all three joined by newlines. Telegram receives the URL in its own parameter and the title and note joined as a caption, and renders a link preview from the URL. Source payload title text (a note) url one payload, two mappings wa.me/?text=… — one field title \n text \n url, composed first, then encoded newlines survive as %0A and render as line breaks t.me/share/url?url=…&text=… — two fields url drives the preview card; text is the caption never repeat the url inside text — it prints twice

Step 2 — Encode Every Interpolated Value

The failure this prevents is silent and common: an unencoded & in a title ends the parameter, and everything after it — usually the URL — disappears.

// safe-params.js
export function buildIntentUrl(base, params) {
  const query = Object.entries(params)
    .filter(([, value]) => value !== undefined && value !== null && value !== '')
    .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
    .join('&');
  return `${base}?${query}`;
}

Using a helper rather than hand-writing each template removes the possibility of forgetting one value. URLSearchParams is an equally good choice with one caveat worth knowing: it encodes spaces as + rather than %20, which some clients render literally as plus signs inside a message body. For message text, encodeURIComponent is the safer default.

Characters that break unencoded links, and what they become:

Character Encoded Breaks what
& %26 Ends the parameter; the rest is dropped
# %23 Everything after it becomes a fragment
? %3F Confuses the query parser
newline %0A Message truncates at the break
+ %2B Read back as a space
/ %2F Safe in a value, but not in a path segment

Step 3 — Render Anchors, Never window.open After an await

Popup blocking follows the same rule as the Web Share API: the navigation must happen synchronously inside the user gesture. Building the URL first and letting a real anchor navigate satisfies it by construction.

// render-targets.js
import { whatsAppUrl, telegramUrl } from './messaging-intents.js';

export function renderShareTargets(container, payload) {
  const targets = [
    { key: 'whatsapp', label: 'WhatsApp', href: whatsAppUrl(payload) },
    { key: 'telegram', label: 'Telegram', href: telegramUrl(payload) }
  ];

  container.replaceChildren(...targets.map(({ key, label, href }) => {
    const item = document.createElement('li');
    const anchor = document.createElement('a');
    anchor.href = href;
    anchor.textContent = label;
    anchor.target = '_blank';
    anchor.rel = 'noopener noreferrer';       // never hand the destination a window reference
    anchor.dataset.target = key;
    item.append(anchor);
    return item;
  }));
}

rel="noopener noreferrer" is required whenever target="_blank" is present: without it the opened page receives a reference to your window through window.opener and can navigate it elsewhere.

Step 4 — Keep a Working Alternative Beside Them

Messaging links are a convenience, not a guarantee. A desktop user without WhatsApp Web signed in lands on a login page rather than a compose window, and that is a dead end unless something else is within reach.

// with-fallback.js
import { renderShareTargets } from './render-targets.js';

export function renderSheet(container, payload, statusEl) {
  renderShareTargets(container, payload);

  const copyItem = document.createElement('li');
  const copyButton = document.createElement('button');
  copyButton.type = 'button';
  copyButton.textContent = 'Copy link';
  copyButton.addEventListener('click', async () => {
    await navigator.clipboard.writeText(payload.url);
    statusEl.textContent = 'Link copied';
  });
  copyItem.append(copyButton);
  container.prepend(copyItem);              // first in the list, first in the tab order
}
What an Unencoded Ampersand Costs The same title containing an ampersand is placed in a share link twice. Without encoding the query parameter ends at the ampersand and the URL is lost, so the recipient receives a fragment. With encoding the whole message including the link arrives intact. Unencoded wa.me/?text=Ops & Safety Guide https://example.com/g/ recipient sees: "Ops" — the link and the rest are gone Encoded with encodeURIComponent wa.me/?text=Ops%20%26%20Safety%20Guide%0Ahttps%3A%2F%2Fexample.com%2Fg%2F recipient sees: "Ops & Safety Guide" then the link on its own line

Failure Modes and Recovery

Symptom Cause Minimal fix
Message truncated at & Value not encoded encodeURIComponent every interpolated part
URL missing from a WhatsApp message Sent as a separate parameter Compose title, text and URL into one text
Telegram shows the link twice URL repeated inside text Keep the link only in url
Spaces arrive as + Built with URLSearchParams Use encodeURIComponent for message bodies
New tab blocked window.open called after an await Set href ahead; let the anchor navigate
Destination can read window.opener target="_blank" without rel Add rel="noopener noreferrer"
Nothing happens on desktop Not signed in to the web client Keep copy-link adjacent as the floor
HTTPS Links Beat Custom Schemes An https link is intercepted by the installed app through universal links, and falls back to the service web page when the app is absent. A custom scheme fails silently with no observable event when nothing is registered to handle it. https://wa.me/?text=… app installed → opens the compose view not installed → opens the web client always a visible outcome and a real navigation you can measure whatsapp://send?text=… app installed → opens the compose view not installed → nothing at all no error, no event to catch the user is left staring at the sheet

Browser and Platform Caveat

Both wa.me and t.me are ordinary HTTPS URLs, which is exactly why they are preferable to the whatsapp:// and tg:// custom schemes: a browser can always open them, falling back to the service’s web interface when the app is not installed, whereas an unhandled custom scheme fails silently with no event you can observe. On mobile the HTTPS link is intercepted by the installed app through its universal-link association, so users still land in the native compose view. On desktop the outcome depends on whether the user is signed in to the web client, which is outside your control — so treat these links as a convenience layered on top of copy-link rather than as the primary path. Neither service accepts files through an intent URL; file hand-off remains exclusive to the native share sheet.

FAQ

Why does my WhatsApp message stop at the first ampersand?

An unencoded ampersand inside the text value ends that query parameter, so everything after it is parsed as a new one and silently dropped. Wrap every interpolated value in encodeURIComponent, including the URL you are sharing.

Does wa.me accept separate title and url parameters?

No. It accepts one text parameter, so the title and the link have to be composed into a single string before encoding. Telegram is the opposite — t.me/share/url takes url and text separately and renders a link preview from the url.

Do these links work when the app is not installed?

Both wa.me and t.me are ordinary HTTPS URLs, so a browser opens the service’s web page rather than failing. That is why they are preferable to custom whatsapp:// or tg:// schemes, which dead-end silently when nothing is registered to handle them.