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-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-10-05", "lastUpdated": "2026-10-05", "order": 5 },
{ "slug": "privacy", "title": "Privacy policy", "version": 1, "effectiveDate": "2026-10-05", "lastUpdated": "2026-10-05", "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-10-05",
"lastUpdated": "2026-10-05",
"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-10-05" → "5 October 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@clustr.app) | 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: 5 October 2026 │ │ ← YOU render this from
│ │ Last Updated: 5 October 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/complete).
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.