Skip to main content
Version: Latest

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 /accept to 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/policies and GET /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.
  • markdown is 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).
  • version is 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

FieldTypeNotes
slugstringURL id and stable key. Use it for navigation and caching.
titlestringScreen title — put it in the nav bar. Don't hardcode titles.
versionintegerBumped by hand when the meaning changes. Typo fixes do not move it.
effectiveDatestring | nullYYYY-MM-DD. Non-null on terms and privacy only.
lastUpdatedstring | nullYYYY-MM-DD. Same.
orderintegerDisplay order. The list is pre-sorted; the field is there so you can re-sort if you ever merge sources.
markdownstringBody. 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:

  1. Fetch GET /api/policies on app start (or on entering Settings). It's cheap.
  2. Store each body you download keyed by slug, together with the version you downloaded it at.
  3. Opening a document: if your cached version for that slug equals the version in 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-display is 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.

ElementMarkdownWhere
Section heading## Headingall six
Sub-heading### Headingcommunity-guidelines
Paragraphplain textall six
Bold**text**all six — most bullets are **Lead-in** - explanation
Bullet list- itemall six
Numbered list1. itemhelp-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
TableGFM pipe tablesafety-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:

  1. Title comes from the API, not from a constant in your navigator. It is the only place the wording lives.
  2. 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 terms and privacy have them. Render the card only when effectiveDate is non-null.
  3. 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

CaseResponseWhat to show
Unknown slug404 POLICY_NOT_FOUNDShouldn't happen if you navigate from the list. Go back and refresh the list.
Malformed slug400 (schema)Same — you built the URL wrong. Slugs are lowercase letters, digits and hyphens.
OfflineCached 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.


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/docsPolicies.

⤓ Download .md