Policy Pages — Mobile Integration Guide
Six documents — About, Help centre, Safety centre, Community guidelines, Terms of
use, Privacy policy — come from the API as versioned markdown. You build
one screen and one renderer; the six pages are that screen with a
different slug.
There is no per-page layout to write, no section-by-section styling, and no copy in the app bundle. When legal text changes, the server changes — you ship nothing.
What's new
- 2026-10-06 — The public Terms and Privacy pages moved to
letsclustr.com/clustrapp/terms-of-use/andletsclustr.com/clustrapp/privacy-policy/. The old/terms/and/privacy/links are now dead, and those paths are reserved for the future company website. If the app or its store listings link to the web pages, update the URLs. The API and the in-app markdown are unchanged. - 2026-09-16 — The text of all six policy documents was replaced.
versiondid not move — it is still1on all six, because nothing is live yet and nobody has consented to the old wording. That means theversion-keyed cache in §3 will not invalidate itself: anything you fetched while building is stale, so clear it by hand once. The first content change after launch will bumpversionand the cache will behave normally from then on. Two facts worth knowing: every contact address in the documents is nowsupport@axiantlabs.com(the old@clustr.appones are dead), and Terms and Privacy now also exist as public web pages atletsclustr.com/terms/and/privacy/(moved 2026-10-06, see above) — those are for the app stores and the website. Keep rendering the markdown in-app; don't swap the screen for a WebView. - 2026-09-10 — Consent to the Terms and Privacy Policy is now recorded automatically at signup — you send nothing for it, and there is still no
POST /acceptto call. Keep the signup consent line as plain text with links; do not add a checkbox. Separately, signup now rejects anyone under 13 with a 422 — handle that on the date-of-birth field. §8 rewritten. - 2026-09-10 — First version. The six policy pages are now served as markdown from
GET /api/policiesandGET /api/policies/:slug— no auth needed, so you can link them from the login screen. Build one markdown screen, not six layouts, and do not hardcode the text in the app: it changes server-side without a release. §4 has a ready-to-paste renderer, §5 lists every markdown element the content actually uses, and §6 covers the Effective/Last-updated card, which is metadata, not markdown — you render it yourself above the body.
Short version
GET /api/policies → [{ slug, title, version, effectiveDate, lastUpdated, order }]
GET /api/policies/:slug → { slug, title, version, effectiveDate, lastUpdated, order, markdown }
- No token. Both are public — the Terms have to be readable before signup.
- The list is already sorted by
order. Render it in the order you receive it. markdownis CommonMark + GFM tables. Render it; never parse it yourself.- The date card at the top of Terms/Privacy is not in the markdown — build it
from
effectiveDate/lastUpdated(§6). versionis an integer bumped by hand on meaning changes. Cache against it (§3).
1 — The endpoints
GET /api/policies
Metadata only, no bodies — a few hundred bytes. This is what the Settings screen lists.
{
"success": true,
"data": [
{ "slug": "about", "title": "About Clustr", "version": 1, "effectiveDate": null, "lastUpdated": null, "order": 1 },
{ "slug": "help-centre", "title": "Help centre", "version": 1, "effectiveDate": null, "lastUpdated": null, "order": 2 },
{ "slug": "safety-centre", "title": "Safety centre", "version": 1, "effectiveDate": null, "lastUpdated": null, "order": 3 },
{ "slug": "community-guidelines", "title": "Community guidelines", "version": 1, "effectiveDate": null, "lastUpdated": null, "order": 4 },
{ "slug": "terms", "title": "Terms of use", "version": 1, "effectiveDate": "2026-09-15", "lastUpdated": "2026-09-15", "order": 5 },
{ "slug": "privacy", "title": "Privacy policy", "version": 1, "effectiveDate": "2026-09-15", "lastUpdated": "2026-09-15", "order": 6 }
]
}
Don't hardcode this list. It arrives sorted; a seventh document is a server change, and if you hardcoded six rows the app won't show it.
GET /api/policies/:slug
The same object plus markdown.
{
"success": true,
"data": {
"slug": "privacy",
"title": "Privacy policy",
"version": 1,
"effectiveDate": "2026-09-15",
"lastUpdated": "2026-09-15",
"order": 6,
"markdown": "This explains what Clustr collects, why, and what rights you have.\n\n---\n\n## What we collect\n…"
}
}
Unknown slug → 404 POLICY_NOT_FOUND.
Field reference
| Field | Type | Notes |
|---|---|---|
slug | string | URL id and stable key. Use it for navigation and caching. |
title | string | Screen title — put it in the nav bar. Don't hardcode titles. |
version | integer | Bumped by hand when the meaning changes. Typo fixes do not move it. |
effectiveDate | string | null | YYYY-MM-DD. Non-null on terms and privacy only. |
lastUpdated | string | null | YYYY-MM-DD. Same. |
order | integer | Display order. The list is pre-sorted; the field is there so you can re-sort if you ever merge sources. |
markdown | string | Body. Present on the detail response only. |
2 — Auth and headers
Neither endpoint takes a token. Sending one is harmless, but don't require a session — these screens are reachable from the login screen and from the signup consent line ("By signing up you agree to our Terms…").
Both responses carry Cache-Control: public, max-age=3600.
3 — Caching, and what version is for
The bodies are small but they are static for months at a time. The pattern:
- Fetch
GET /api/policieson app start (or on entering Settings). It's cheap. - Store each body you download keyed by
slug, together with theversionyou downloaded it at. - Opening a document: if your cached
versionfor that slug equals theversionin the list, render the cached body. Otherwise fetch it again.
version moves only on a material change — new clause, changed retention
period, new contact. A comma or a typo fix deliberately leaves it alone, so this
number is safe to build "the user must re-read this" logic on later.
Show the cached copy immediately and refresh behind it. A policy screen that spinners on a cold network is worse than a policy screen that is one revision stale for two seconds.
4 — The renderer
One component, used by all six screens.
Dependency
Markdown-to-React-Native is a solved problem — don't hand-roll a parser.
react-native-markdown-display— style-object API, the least code. The snippet below targets it. The original package has been quiet for a while;@ronradtke/react-native-markdown-displayis the commonly used fork with the same API. Check which one is currently maintained before you install — either way the code below is unchanged apart from the import.react-native-marked— the alternative if you'd rather supply per-node renderers than a style sheet.
npm install react-native-markdown-display
# or: npm install @ronradtke/react-native-markdown-display
PolicyScreen.tsx
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Linking,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import Markdown from 'react-native-markdown-display';
const API_BASE = 'https://api.letsclustr.com'; // your existing config value
interface Policy {
slug: string;
title: string;
version: number;
effectiveDate: string | null;
lastUpdated: string | null;
order: number;
markdown: string;
}
/** "2026-09-15" → "15 September 2026" (matches the design). */
function formatDate(iso: string): string {
const [y, m, d] = iso.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
}
export function PolicyScreen({ slug }: { slug: string }) {
const [policy, setPolicy] = useState<Policy | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetch(`${API_BASE}/api/policies/${slug}`)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((json) => {
if (!cancelled) setPolicy(json.data);
})
.catch(() => {
if (!cancelled) setError("We couldn't load this page. Check your connection.");
});
return () => {
cancelled = true;
};
}, [slug]);
// mailto: and https: both go to the OS. Return false so the library does not
// also try to handle the press.
const onLinkPress = useCallback((url: string) => {
Linking.openURL(url).catch(() => {});
return false;
}, []);
if (error) return <Text style={styles.error}>{error}</Text>;
if (!policy) return <ActivityIndicator style={styles.loading} />;
return (
<ScrollView
style={styles.screen}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* Metadata card — NOT part of the markdown. See §6. */}
{policy.effectiveDate && (
<View style={styles.dateCard}>
<Text style={styles.dateLine}>
<Text style={styles.dateLabel}>Effective Date: </Text>
{formatDate(policy.effectiveDate)}
</Text>
{policy.lastUpdated && (
<Text style={styles.dateLine}>
<Text style={styles.dateLabel}>Last Updated: </Text>
{formatDate(policy.lastUpdated)}
</Text>
)}
</View>
)}
<Markdown style={markdownStyles} onLinkPress={onLinkPress}>
{policy.markdown}
</Markdown>
</ScrollView>
);
}
The style map
Every key below corresponds to an element the content actually contains — see §5. Swap the raw values for your design tokens; the shape is what matters.
const INK = '#111111';
const MUTED = '#6B7280';
const RULE = '#E5E7EB';
const CARD = '#FFFFFF';
const LINK = '#2563EB';
const SURFACE = '#F5F6F8';
const markdownStyles = StyleSheet.create({
body: { color: INK, fontSize: 17, lineHeight: 26 },
// Content starts at h2 — the page title comes from `policy.title` in the nav
// bar, so there is no h1 in any document.
heading2: { fontSize: 26, fontWeight: '400', color: INK, marginTop: 32, marginBottom: 12 },
heading3: { fontSize: 18, fontWeight: '700', color: INK, marginTop: 24, marginBottom: 8 },
paragraph: { marginTop: 0, marginBottom: 16 },
strong: { fontWeight: '700' },
em: { fontStyle: 'italic' },
link: { color: LINK, textDecorationLine: 'underline' },
// `---` between sections. The design draws a hairline with generous air.
hr: { backgroundColor: RULE, height: 1, marginVertical: 28 },
// Blockquote === the design's callout card. See §5.
blockquote: {
backgroundColor: CARD,
borderRadius: 16,
paddingVertical: 8,
paddingHorizontal: 16,
marginVertical: 8,
borderLeftWidth: 0, // kill the library's default quote bar
},
bullet_list: { marginBottom: 16 },
ordered_list: { marginBottom: 16 },
list_item: { flexDirection: 'row', marginBottom: 8 },
bullet_list_icon: { marginLeft: 4, marginRight: 10, lineHeight: 26 },
ordered_list_icon: { marginLeft: 4, marginRight: 10, lineHeight: 26 },
// GFM table — `safety-centre` only.
table: { borderWidth: 1, borderColor: RULE, borderRadius: 12, overflow: 'hidden', marginBottom: 16 },
thead: { backgroundColor: SURFACE },
th: { padding: 12, fontWeight: '700', color: INK },
tr: { borderBottomWidth: 1, borderColor: RULE },
td: { padding: 12, color: INK },
});
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: SURFACE },
content: { paddingHorizontal: 20, paddingBottom: 48 },
loading: { marginTop: 64 },
error: { margin: 20, color: MUTED, fontSize: 16, lineHeight: 24 },
dateCard: {
backgroundColor: CARD,
borderRadius: 16,
padding: 16,
marginTop: 16,
marginBottom: 8,
},
dateLine: { fontSize: 17, lineHeight: 26, color: INK },
dateLabel: { fontWeight: '700' },
});
5 — What the markdown actually contains
The complete list. If your renderer handles these, it handles all six documents and every future one — new content will not introduce elements outside this set without a note in What's new.
| Element | Markdown | Where |
|---|---|---|
| Section heading | ## Heading | all six |
| Sub-heading | ### Heading | community-guidelines |
| Paragraph | plain text | all six |
| Bold | **text** | all six — most bullets are **Lead-in** - explanation |
| Bullet list | - item | all six |
| Numbered list | 1. item | help-centre, safety-centre |
| Link | [label](mailto:support@axiantlabs.com) | all six. mailto: only today — treat https: as possible and route both through Linking.openURL. |
| Divider | --- | all six, between sections |
| Callout card | > … (blockquote) | community-guidelines, safety-centre, help-centre |
| Table | GFM pipe table | safety-centre only — the helplines table |
Not used, don't build for: images, code blocks, inline code, nested lists, h1, h4+, task lists, footnotes.
The blockquote convention
The design draws certain blocks as a white rounded card on the grey page — the
"quick version" rules, the two urgent-report situations, the four post kinds,
the "we read everything" note. Markdown has no card primitive, so a blockquote
means a callout card. Style blockquote as the card and remove the default
left quote bar; there is no actual quotation anywhere in the six documents.
The one table
safety-centre ends with a three-column helplines table. Give the table
horizontal scroll on narrow screens or let the columns wrap — the third column
holds phone numbers that must not truncate.
6 — Assembling the screen
┌─────────────────────────────────────┐
│ ‹ Privacy policy │ ← nav bar, from `policy.title`
├─────────────────────────────────────┤
│ ┌─────────────────────────────────┐ │
│ │ Effective Date: 15 Sept 2026 │ │ ← YOU render this from
│ │ Last Updated: 15 Sept 2026 │ │ effectiveDate / lastUpdated
│ └─────────────────────────────────┘ │
│ │
│ This explains what Clustr collects… │ ← <Markdown>{policy.markdown}</Markdown>
│ ─────────────────────────────────── │
│ What we collect │
│ … │
└─────────────────────────────────────┘
Three rules:
- Title comes from the API, not from a constant in your navigator. It is the only place the wording lives.
- The date card is metadata, not markdown. It is deliberately not in the
body: dates are structured fields you format for the locale, and only
termsandprivacyhave them. Render the card only wheneffectiveDateis non-null. - Everything else is the markdown component. Resist adding per-slug
conditionals — the moment one screen special-cases
privacy, the "one renderer" property is gone and the next document needs code again.
Settings list — same data, no bodies:
const { data } = await fetch(`${API_BASE}/api/policies`).then((r) => r.json());
// data is pre-sorted; render straight through
data.map((p) => <Row key={p.slug} title={p.title} onPress={() => open(p.slug)} />);
7 — Errors
| Case | Response | What to show |
|---|---|---|
| Unknown slug | 404 POLICY_NOT_FOUND | Shouldn't happen if you navigate from the list. Go back and refresh the list. |
| Malformed slug | 400 (schema) | Same — you built the URL wrong. Slugs are lowercase letters, digits and hyphens. |
| Offline | — | Cached body if you have one (§3), otherwise a retry message. Never a blank screen. |
Because these screens are reachable while logged out, don't route their failures through any interceptor that redirects to login on error.
8 — Consent, and what's still missing
Consent is recorded for you. As of 2026-09-10 the server writes a consent
record at signup — one per document, stamped with the version that was current
at that moment. Both signup paths do it (POST /api/auth/register and
POST /api/onboarding/signup).
You send nothing. No new field, no checkbox value, no version number. The
version is taken from the server's own copy of the documents; a request that
tries to supply one is ignored. There is no POST /accept to call — signing up
is the act of accepting.
Build the signup consent line as plain text with links:
By continuing you agree to our Terms of use and Privacy policy
Link both through the same PolicyScreen you already have. Don't build a
consent checkbox — the line is the disclosure, and a checkbox would imply a
separate acceptance step that doesn't exist.
Also new, and it does affect you: signup now rejects anyone under 13 with a 422. Handle it on the date-of-birth field. See the Auth and Onboarding guides.
Still not built
- Re-consent when a policy changes. No "you must accept the new Terms" gate. The Terms promise 30 days' notice of a material change, not re-acceptance.
- An annual reminder, which the Terms do promise.
- Verified parental consent (DPDP, 2027). When it lands it will be a real endpoint — a guardian is a different person from the account holder, so it cannot ride on the user's signup.
None of these change the two endpoints in this guide.
9 — Quick reference
GET /api/policies # list, no auth, pre-sorted, no bodies
GET /api/policies/about # About Clustr
GET /api/policies/help-centre # Help centre
GET /api/policies/safety-centre # Safety centre (has the one table)
GET /api/policies/community-guidelines # Community guidelines (has h3 sub-headings)
GET /api/policies/terms # Terms of use (has dates)
GET /api/policies/privacy # Privacy policy (has dates)
Swagger: /api/docs → Policies.