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