Skip to main content
Version: Latest

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 ksn://posts/:id
https://letsclustr.com/users/:username ksn://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 — Housekeeping only: the link site now runs on Firebase Hosting instead of Cloudflare Pages. Nothing you build changes — same URLs, same ksn:// grammar, same previews. The only visible difference is in §9: a pre-domain test deploy shows up on a *.web.app hostname, not *.pages.dev, and verifying it still proves nothing about letsclustr.com.
  • 2026-09-09 — First version. Share URLs now live at letsclustr.com/posts/:id and /users/:username (they used to be specced as /p/:id — that shape is gone). Profiles are shareable via a new GET /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> and ksn://<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

ScreenHTTPSCustom schemeWhere the link comes from
Post detailletsclustr.com/posts/:postIdksn://posts/:postIdPOST /api/posts/:id/share, notifications
Post, scrolled to a commentletsclustr.com/posts/:postId?commentId=:idksn://posts/:postId?commentId=:idcomment + reply notifications
Profileletsclustr.com/users/:usernameksn://users/:usernameGET /api/users/:username/share, follow notifications
Conversationksn://chat/:conversationId?messageId=:idchat notifications
Eventksn://events/:idevent 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.


Profile

GET /api/users/:username/share
Authorization: Bearer <accessToken>
{ "success": true, "data": { "url": "https://letsclustr.com/users/anya_07" } }
CodeWhen
404No such user, account deactivated, blocked in either direction, or moderation-hidden
401Missing/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.

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

Keep the existing ksn:// filter as a separate <intent-filter> — it is what notifications and local development use, and it needs no verification.


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" }
// ksn://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
}
}

ksn://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.


5 — Auth state on arrival

Three cases, and the middle one is the one that gets forgotten:

StateBehaviour
Installed, logged inNavigate straight to the target.
Installed, logged outStash the parsed target, run the normal login flow, then navigate to it.
Not installedThe 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 ksn:// 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.


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.

ValueUsed in
Apple Team IDapple-app-site-association
iOS bundle idapple-app-site-association
App Store idstore button + iOS smart banner
Android package nameassetlinks.json + store button
Release SHA-256 fingerprintassetlinks.json
Play App Signing SHA-256 fingerprintassetlinks.json
Debug SHA-256 fingerprintassetlinks.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 ksn://, which needs no domain, no TLS and no verification:

xcrun simctl openurl booted "ksn://posts/019f45c6-0000-7000-8000-000000000000"
xcrun simctl openurl booted "ksn://users/anya_07"

adb shell am start -a android.intent.action.VIEW -d "ksn://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.app preview hostname means nothing. Association files are per-domain; only letsclustr.com counts.


10 — Checklist

  • applinks:letsclustr.com entitlement (iOS)
  • autoVerify intent-filter for /posts + /users (Android)
  • ksn:// intent-filter kept alongside it
  • One parseDeepLink handling both schemes
  • Unknown path → home screen, no crash
  • ?ref= ignored, ?commentId= honoured
  • pendingDeepLink survives login, cleared on logout
  • Share button calls the API for the URL instead of building it
  • Identifiers from §8 sent to backend
⤓ Download .md