Deep Links & Share Links — Mobile Integration Guide
Every link Clustr hands out — from the share sheet, from a push notification, from a chat message — points at one of two schemes for the same screens:
https://letsclustr.com/posts/:id clustr://posts/:id
https://letsclustr.com/users/:username clustr://users/:username
Same paths, different scheme. Write one parser, not two. This doc is the contract for the React Native client: what the URLs mean, what to configure on each platform, how to test before any of it is verified, and what we still need from you.
What's new
- 2026-09-10 — The custom scheme is now
clustr://, notksn://. Same paths, same query params — only the scheme moved. Swap it in your iOS URL type, your Android<intent-filter>andparseDeepLink, and delete the oldksn://registration: the API will never send that scheme again. - 2026-09-10 — Housekeeping only: the link site now runs on Firebase Hosting instead of
Cloudflare Pages. Nothing you build changes — same URLs, same
clustr://grammar, same previews. The only visible difference is in §9: a pre-domain test deploy shows up on a*.web.apphostname, not*.pages.dev, and verifying it still proves nothing aboutletsclustr.com. - 2026-09-09 — First version. Share URLs now live at
letsclustr.com/posts/:idand/users/:username(they used to be specced as/p/:id— that shape is gone). Profiles are shareable via a newGET /api/users/:username/share. Nothing here is verified yet: send us the bundle ids and signing fingerprints in §8 and Universal / App Links start working.
Short version
GET /api/users/:username/share → { url } (new)
POST /api/posts/:id/share → { url, shareCode, channel } (unchanged, new URL shape)
- Never build a link yourself. Ask the API — the base URL differs per environment.
- Route
https://letsclustr.com/<path>andclustr://<path>through the same handler. - Strip and ignore
?ref=. Keep?commentId=. - Logged out on arrival? Stash the target, log in, then navigate.
1 — The URL grammar
| Screen | HTTPS | Custom scheme | Where the link comes from |
|---|---|---|---|
| Post detail | letsclustr.com/posts/:postId | clustr://posts/:postId | POST /api/posts/:id/share, notifications |
| Post, scrolled to a comment | letsclustr.com/posts/:postId?commentId=:id | clustr://posts/:postId?commentId=:id | comment + reply notifications |
| Profile | letsclustr.com/users/:username | clustr://users/:username | GET /api/users/:username/share, follow notifications |
| Conversation | — | clustr://chat/:conversationId?messageId=:id | chat notifications |
| Event | — | clustr://events/:id | event notifications |
Chat and events are in-app only for now. There is no
letsclustr.com/chat/... — the domain does not serve that path, the association
files do not claim it, and your intent-filter should not list it. When group
links land, all four move together.
?ref= is opaque
POST /api/posts/:id/share appends ?ref=<10 chars> for share attribution:
https://letsclustr.com/posts/019f45c6-…?ref=S6kvTbrHCR
Treat it as noise. Don't parse it, don't branch on it, don't send it anywhere.
"/posts/019f45c6-…?ref=S6kvTbrHCR" means postId = "019f45c6-…", full stop.
Nothing reads ref today; if that changes you'll get a new endpoint, not new
parsing rules.
Ids and usernames
postId is a UUIDv7. username matches [a-zA-Z0-9_] and is case-insensitive
on the server, but always arrives in canonical casing — the API builds the link
from the stored username, so /users/BOB will never be minted even if someone
types it.
2 — Getting a link
Profile
GET /api/users/:username/share
Authorization: Bearer <accessToken>
{ "success": true, "data": { "url": "https://letsclustr.com/users/anya_07" } }
| Code | When |
|---|---|
404 | No such user, account deactivated, blocked in either direction, or moderation-hidden |
401 | Missing/expired token |
A private profile is shareable and returns 200 — the recipient lands on the
locked profile with a follow button, which is the point. Only "this account
should not be discoverable" cases 404.
Post
Unchanged, see mobile-posts-guide §18. Only the
URL shape moved: /posts/:id, not /p/:id.
Always call the endpoint rather than string-building
https://letsclustr.com/posts/${id} on the client. The base URL is server
config; a staging build must not hand out production links.
3 — Platform configuration
iOS — Associated Domains
Add to the entitlement:
applinks:letsclustr.com
Handle incoming links in application(_:continue:restorationHandler:) (or the
RN Linking equivalent). iOS fetches
https://letsclustr.com/.well-known/apple-app-site-association at install time;
if it doesn't match your Team ID + bundle id, links open Safari instead — see §7.
Android — App Links
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="letsclustr.com" />
<data android:pathPrefix="/posts" />
<data android:pathPrefix="/users" />
</intent-filter>
Register clustr:// as a separate <intent-filter> — it is what notifications
and local development use, and it needs no verification. If you already wired up
ksn://, replace it: the API no longer emits that scheme.
4 — Parsing
One function, both schemes. The path is identical, so strip the scheme + host and switch on the first segment:
// letsclustr.com/posts/abc?commentId=x -> { kind: "post", id: "abc", commentId: "x" }
// clustr://posts/abc?commentId=x -> the same
export function parseDeepLink(raw: string) {
const u = new URL(raw);
const [head, id] = u.pathname.replace(/^\/+/, "").split("/");
const commentId = u.searchParams.get("commentId") ?? undefined;
switch (head) {
case "posts": return id ? { kind: "post", id, commentId } : null;
case "users": return id ? { kind: "profile", username: id } : null;
case "chat": return id ? { kind: "conversation", id,
messageId: u.searchParams.get("messageId") ?? undefined } : null;
case "events": return id ? { kind: "event", id } : null;
default: return null; // unknown → open the app at home, never crash
}
}
clustr://posts/abc parses as host posts, path /abc in some URL
implementations — RN's included. Normalise before splitting, or match on the raw
string. An unrecognised link must land the user on the home screen, not an error.
Switch on the path, never on the scheme. Notification rows created before the
2026-09-10 rename still carry ksn:// in data.deepLink until they age out (a
backfill is queued), and those taps are routed by your own code, not by the OS —
so a path-based parser handles them with no ksn:// registration anywhere.
5 — Auth state on arrival
Three cases, and the middle one is the one that gets forgotten:
| State | Behaviour |
|---|---|
| Installed, logged in | Navigate straight to the target. |
| Installed, logged out | Stash the parsed target, run the normal login flow, then navigate to it. |
| Not installed | The OS opens the web fallback page (§6). |
The logged-out case is a single variable — pendingDeepLink, consumed once
after the first successful authenticated render — and it rescues the most common
real-world path, because the person receiving a shared link is often the one who
signed out months ago. Clear it on logout and on consumption so a stale target
can't fire later.
This is not deferred deep linking (install → land on the shared post). That is deliberately out of scope: a fresh install lands on home.
6 — What the web fallback page does
When the app isn't installed, the URL opens letsclustr.com in a browser. That
page is static and deliberately dumb:
- An Open in Clustr button (fires the
clustr://equivalent, in case the app is installed but the domain isn't verified yet). - App Store and Play Store buttons.
- No automatic redirect. Nothing bounces to the store on its own — that misbehaves for desktop visitors, crawlers and preview bots.
It never shows the post or the profile. It cannot: it's a static file with no API access.
7 — What link previews show
When a Clustr link is pasted into WhatsApp, Instagram, iMessage or anywhere else that unfurls URLs, the metadata served is identical for every link:
Clustr — Someone shared this with you. Open it in the Clustr app.
No post title, no body text, no photo, no username, no avatar. Clustr is a platform for minors and those crawlers cache what they fetch indefinitely, so nothing about the linked content leaves the app. Don't build UI that promises the recipient a rich preview, and don't file it as a bug.
The in-app share sheet is different: GET /api/posts/:id/share/preview
returns the real title, description and image, auth-gated, for prefilling the OS
share sheet. That data never reaches the web page.
8 — What we need from you
Until these land, the association files carry REPLACE_* placeholders. In that
state the fallback page and link previews work and native app association does
not work at all — an unverified file is inert, not broken. If you test today
and links open Safari or Chrome instead of the app, that is the expected result,
not a bug to chase.
| Value | Used in |
|---|---|
| Apple Team ID | apple-app-site-association |
| iOS bundle id | apple-app-site-association |
| App Store id | store button + iOS smart banner |
| Android package name | assetlinks.json + store button |
| Release SHA-256 fingerprint | assetlinks.json |
| Play App Signing SHA-256 fingerprint | assetlinks.json |
| Debug SHA-256 fingerprint | assetlinks.json (so debug builds verify too) |
9 — Testing
Locally, today
Universal Links and Android App Links cannot work against localhost — both
require HTTPS on a domain serving a verified association file. So local link
testing uses clustr://, which needs no domain, no TLS and no verification:
xcrun simctl openurl booted "clustr://posts/019f45c6-0000-7000-8000-000000000000"
xcrun simctl openurl booted "clustr://users/anya_07"
adb shell am start -a android.intent.action.VIEW -d "clustr://posts/019f45c6-…"
Exercise the logged-out path too — sign out first, then fire the same command.
HTTPS, once identifiers are in
# should open the app, not the browser
adb shell am start -a android.intent.action.VIEW -d "https://letsclustr.com/posts/<id>"
adb shell pm verify-app-links --re-verify <package>
adb shell pm get-app-links <package> # expect: verified
On iOS, tap a link from Notes or Messages — not from Safari's address bar, which deliberately bypasses Universal Links. Apple's AASA validator reads the production domain.
A green check on any
*.web.apppreview hostname means nothing. Association files are per-domain; onlyletsclustr.comcounts.
10 — Checklist
-
applinks:letsclustr.comentitlement (iOS) -
autoVerifyintent-filter for/posts+/users(Android) -
clustr://intent-filter alongside it (oldksn://one removed) - One
parseDeepLinkhandling both schemes - Unknown path → home screen, no crash
-
?ref=ignored,?commentId=honoured -
pendingDeepLinksurvives login, cleared on logout - Share button calls the API for the URL instead of building it
- Identifiers from §8 sent to backend