Content Module API & Integration Guide
Admin and public contracts for section-wise CMS payloads with Tiptap JSON validation for Happy House.
Audience: Frontend/admin-panel engineers and backend integrators Scope: Content page metadata, section payload CRUD, public/mobile page reads
Content Module - API & Integration Guide
1. Quick Metadata
- Module:
Content - Auth models:
- Admin routes:
JwtAuthGuard + RoleGuard + @Permissions(...) - Public route:
@Public()
- Admin routes:
- Base routes:
- Admin:
/api/admin/content/pages - Public:
/api/content/pages - Mobile mirror:
/api/mobile/content/pages
- Admin:
- Response envelope:
ResponseDto<T> - Swagger tags:
Content Pages (Admin)Content Pages
1.1 Read Caching (Redis)
Content read endpoints are cache-aside:
GET /api/content/pages/:pageKeyandGET /api/mobile/content/pages/:pageKeyuse public keyspacecontent:page:public:GET /api/admin/content/pages/:pageKeyuses admin keyspacecontent:page:admin:
TTL envs:
CONTENT_PAGE_PUBLIC_CACHE_TTL_SECONDS(default300)CONTENT_PAGE_ADMIN_CACHE_TTL_SECONDS(default120)
Invalidation:
- successful
PATCH /meta,PUT /sections/:sectionKey, andDELETE /sections/:sectionKeyinvalidate both admin and public cache keys for that page. - Redis failures do not fail API responses; service falls back to DB flow and logs warnings.
2. Page and Section Keys
2.1 Page keys
homeaboutfaqprivacy_policyterms_of_servicereturn_policyglobal_footerglobal_headercontactblogs
global_header is the site's top utility bar — the contact points, the promotional strip and the
utility links. It exists so that bar has an owner; before it, every value in it was hard-coded in
the storefront.
terms_of_service is declared but deliberately unseeded: it needs approved legal text, and an
invented terms page is worse than an absent one.
2.2 Section keys
herohow_it_worksfaq_introrelated_blogs_introcta_bannerquotestory_blockswhy_choosedocumentdata_flow_summarybrand_blurbquick_linkslegal_linkscopyrightcontact_infocontact_formabout_statsour_missionannouncement_bar
2.3 Page-to-section compatibility
| Page key | Allowed sections |
|---|---|
home | hero, how_it_works, faq_intro, related_blogs_intro, cta_banner |
about | hero, how_it_works, about_stats, why_choose, our_mission |
faq | hero, faq_intro |
privacy_policy | document, data_flow_summary |
terms_of_service | document |
return_policy | document |
global_footer | brand_blurb, quick_links, contact_info, legal_links, copyright |
global_header | announcement_bar, contact_info, quick_links |
contact | hero, contact_info, contact_form |
blogs | hero |
The header reuses the footer's contact_info and quick_links rather than declaring header-only
equivalents. The map above is a per-page allowlist, so reuse costs nothing and a section behaves
identically wherever it appears.
Social icons are deliberately absent from global_header. They are owned by the config module's
social-links surface, which the footer already renders from; a second copy here would be two places
to change one set of links.
Invalid pair behavior:
- HTTP
400 - errorCode
CONTENT_SECTION_KEY_INVALID
3. Admin Endpoints
3.1 Get page (admin)
| Aspect | Value |
|---|---|
| Method | GET |
| Path | /api/admin/content/pages/:pageKey |
| Auth | JwtAuthGuard + RoleGuard |
| Permission | Content_READ |
| Throttle | 30/minute |
| Response | ResponseDto<ContentPageResponseDto> |
Behavior:
- auto-creates page if missing (with module default title)
- returns all sections, including
isEnabled=false
3.2 Update page meta
| Aspect | Value |
|---|---|
| Method | PATCH |
| Path | /api/admin/content/pages/:pageKey/meta |
| Permission | Content_UPDATE |
| Throttle | 10/minute |
| Body | UpdateContentPageMetaDto |
| Response | ResponseDto<ContentPageResponseDto> |
Body fields:
title?: string (max 255)seoId?: string | null(UUID or null)
SEO validation:
- if non-null
seoIddoes not exist ->400 CONTENT_SEO_NOT_FOUND
3.3 Upsert section
| Aspect | Value |
|---|---|
| Method | PUT |
| Path | /api/admin/content/pages/:pageKey/sections/:sectionKey |
| Permission | Content_UPDATE |
| Throttle | 10/minute |
| Body | UpsertContentSectionDto |
| Response | ResponseDto<ContentSectionResponseDto> |
Body fields:
payloadJson: Record<string, unknown>(required)position?: number(default0, min0)isEnabled?: boolean(defaulttrue)
Behavior:
- validates
sectionKeycompatibility withpageKey - validates
payloadJsonagainst section schema - auto-creates page if missing
- upserts by unique
(pageId, sectionKey)
3.4 Delete section
| Aspect | Value |
|---|---|
| Method | DELETE |
| Path | /api/admin/content/pages/:pageKey/sections/:sectionKey |
| Permission | Content_DELETE |
| Throttle | 10/minute |
| Response | ResponseDto<void> |
Behavior:
- page must already exist
- section row must already exist
- otherwise returns
404 CONTENT_NOT_FOUND
4. Public + Mobile Endpoints
4.1 Get page (public)
| Aspect | Value |
|---|---|
| Method | GET |
| Path | /api/content/pages/:pageKey |
| Auth | Public |
| Response | ResponseDto<ContentPageResponseDto> |
Behavior:
- does not auto-create pages
- missing page ->
404 CONTENT_NOT_FOUND - returns only enabled sections
- ordered by
position ASC, thenid ASC
4.2 Get page (mobile-composed)
| Aspect | Value |
|---|---|
| Method | GET |
| Path | /api/mobile/content/pages/:pageKey |
| Auth | Public |
| Response | ResponseDto<ContentPageResponseDto> |
Behavior is identical to /api/content/pages/:pageKey.
5. DTO Contract
5.1 ContentPageResponseDto
{
"id": 1,
"pageKey": "home",
"title": "Home Page",
"seoId": null,
"seo": null,
"sections": [],
"createdAt": "2026-07-23T12:00:00.000Z",
"updatedAt": "2026-07-23T12:15:00.000Z"
}5.2 ContentSectionResponseDto
{
"id": 11,
"sectionKey": "hero",
"position": 0,
"isEnabled": true,
"payloadJson": {
"heading": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Happy House" }] }] },
"description": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Furniture and homeware for every room." }] }] }
},
"createdAt": "2026-07-23T12:00:00.000Z",
"updatedAt": "2026-07-23T12:15:00.000Z"
}6. Validation Rules
6.1 Display text fields
Display text is Tiptap JSON and must look like:
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Example" }]
}
]
}Rules:
typemust bedoccontentmust be non-empty
6.2 URL fields
Allowed:
https://...http://.../relative-path
Rejected:
ftp://...javascript:...- empty strings
6.3 Field-size constraints
| Field class | Limit |
|---|---|
| label (rich) | 60 chars equivalent |
| heading (rich) | 110 chars equivalent |
| description (rich) | 320 chars equivalent |
| card title (rich) | 60 chars equivalent |
| card description (rich) | 220 chars equivalent |
| image alt | 255 chars |
| iconKey | 80 chars |
7. Section Payload Schemas and Examples
All examples below are valid payloadJson values for
PUT /sections/:sectionKey.
7.1 hero
Required:
heading(Tiptap)description(Tiptap)
No images, no CTA, no label — minimal hero across pages.
{
"heading": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Happy House" }] }] },
"description": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Furniture and homeware for every room." }] }] }
}7.2 how_it_works
Required:
heading(Tiptap)steps[]length1..12
Each step:
title(Tiptap)description(Tiptap)- optional
icon(string)
Optional:
description(Tiptap)
{
"heading": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "How It Works" }] }] },
"steps": [
{
"title": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Enter Letters" }] }] },
"description": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Type your rack of letters." }] }] }
}
]
}7.3 faq_intro
Required:
heading(Tiptap)description(Tiptap)
Optional:
label(Tiptap)
FAQ items are fetched from the backend FAQs API — the CMS only stores the intro copy.
{
"heading": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Frequently Asked Questions" }] }] },
"description": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Everything you need to know about shopping with Happy House." }] }] }
}7.4 related_blogs_intro
Required:
heading(Tiptap)
Blog data is fetched from the backend blogs API.
{
"heading": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "From Our Blog" }] }] }
}7.5 cta_banner
Required:
title(Tiptap)description(Tiptap)ctaLabel(string)href(safe URL)
{
"title": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Ready to Furnish Your Home?" }] }] },
"description": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Browse our latest furniture and homeware collection." }] }] },
"ctaLabel": "Shop Now",
"href": "/sofas"
}7.6 quote
Required:
text(Tiptap)
Optional:
author(Tiptap)
{
"text": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "A house is made of walls and beams; a home is built with love and dreams." }] }] },
"author": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Anonymous" }] }] }
}7.7 story_blocks
Required:
blocks[]length1..10
Block schema:
label,heading,description(Tiptap)image(src,alt)
Optional:
overlayLines[](up to 6 Tiptap lines)reverse(boolean)
{
"blocks": [
{
"label": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Our Story" }] }] },
"heading": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Built for Homemakers" }] }] },
"description": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Happy House started as a small showroom for people who care about their homes." }] }] },
"image": { "src": "https://cdn.example.com/about-story.webp", "alt": "About us" }
}
]
}7.8 why_choose
Required:
heading(Tiptap)cards[]length1..12
Card schema:
title(Tiptap)description(Tiptap)iconKey(string)
Optional:
label(Tiptap)description(Tiptap)
{
"heading": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Why Choose Us" }] }] },
"cards": [
{
"title": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Wide Selection" }] }] },
"description": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Thousands of furniture and homeware products in stock." }] }] },
"iconKey": "catalog"
}
]
}7.9 document (legal pages)
Used by:
privacy_policyterms_of_service
Required:
eyebrow(string)title(string)summary(string)lastUpdated(string)sections[](length1..50)
sections[] item:
id(kebab-case string)title(string)paragraphs[](length1..20)- optional
bullets[](length1..40)
{
"eyebrow": "Privacy",
"title": "Privacy Policy",
"summary": "We respect your privacy and protect your personal information.",
"lastUpdated": "Last updated: July 20, 2026",
"sections": [
{
"id": "information-we-collect",
"title": "1. Information We Collect",
"paragraphs": [
"We collect account and usage data to improve our services."
],
"bullets": [
"Email address",
"Order history",
"Device information"
]
}
]
}7.10 data_flow_summary
Required:
items[]length1..20
Each item:
title(string)description(string)
{
"items": [
{ "title": "Collection", "description": "We collect only data you provide." },
{ "title": "Processing", "description": "Data is processed to deliver order and account features." }
]
}7.11 Footer sections
brand_blurb
Required:
logo:{ src, alt }description(Tiptap)
{
"logo": { "src": "/logo.svg", "alt": "Happy House" },
"description": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Furniture and homeware, delivered to your door." }] }] }
}quick_links
Required:
columns[]length1..10
Column: title (string) + links[] each { label, href }
{
"columns": [
{
"title": "Shop",
"links": [
{ "label": "Sofas", "href": "/sofas" },
{ "label": "Living Room", "href": "/living-room" }
]
}
]
}contact_info (footer variant)
Used on global_footer and on global_header. Shape: title + contacts[] each
{ label, href?, icon }, plus optional address and openingHours.
icon is a two-value enum, phone | email. That is why the address and the opening hours are
their own fields rather than more contact rows — there is no icon for them, and an address is not a
contact method.
address is a single free-text block rather than structured lines, because its consumers are a
rendered paragraph and a map query string, neither of which wants components. The embedded map
derives its URL entirely from this string, so setting it is also what makes the map editable.
openingHours is capped at seven rows: it describes a week.
Both new fields are optional, and deliberately so — every footer payload seeded before they existed remains valid, where a required field would have failed every render for one cache TTL after deploy.
{
"title": "Contact Us",
"contacts": [
{ "label": "Email", "href": "https://example.com/contact", "icon": "email" },
{ "label": "Phone", "icon": "phone" }
],
"address": "New Road, Kathmandu 44600",
"openingHours": [
{ "label": "Sun – Fri", "value": "10:00 – 19:00" },
{ "label": "Saturday", "value": "Closed" }
]
}href accepts absolute http(s) or site-relative URLs only. tel: and mailto: are rejected by
safeUrlSchema, so a renderer builds those from the label — which is why the seeded rows carry no
href.
contact_info (contact-page variant)
Used on contact page. Shape: email, phone, responseTime.
{
"email": "support@happyhouse.example",
"phone": "+1-800-555-0199",
"responseTime": "Within 24 hours"
}contact_info is a union of the two shapes above, and the validator picks an arm by trying
both — it does not know which page it is validating. The section is allowed on global_footer,
global_header and contact, so either shape validates on any of them.
A renderer must branch on the shape it receives rather than assume contacts exists. Discriminating
the union per page would be the stricter model, but it changes validation for already-seeded
contact rows, which is a content migration rather than a schema change.
announcement_bar
Used on global_header. The promotional strip across the top of the site.
{
"text": "Free delivery on orders over Rs. 50,000",
"href": "/shipping",
"isDismissible": true
}| Field | Rules |
|---|---|
text | required, 1–160 chars, plain text |
href | optional, absolute http(s) or site-relative |
isDismissible | optional boolean, a rendering hint |
Plain text rather than a Tiptap document: the strip renders on one line inside a fixed-height bar, and a rich-text field there would let an operator insert a heading or a list the bar cannot lay out.
href is optional because the strip is usually a statement, not a link — requiring one would force
an operator to invent a destination for a sentence.
This section states an offer; it does not create one. There is no free-delivery threshold
anywhere in cart, checkout or shipping — shipping_rate carries only district, fee and an active
flag, and free shipping exists solely as a FREESHIP coupon type. Nothing reads this text to decide
a price.
To end a campaign, disable the section (isEnabled on the section row) rather than deleting it —
deleting loses the copy.
legal_links
Required:
links[]length1..40, each{ label, href }
{
"links": [
{ "label": "Privacy Policy", "href": "/privacy-policy" },
{ "label": "Terms of Service", "href": "/terms-of-service" }
]
}copyright
Required:
text(Tiptap)
{
"text": {
"type": "doc",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "© 2026 Happy House. All rights reserved." }] }
]
}
}7.12 contact_form
Required:
heading(Tiptap)description(Tiptap)submitLabel(string)
{
"heading": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Send Us a Message" }] }] },
"description": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "We'd love to hear from you." }] }] },
"submitLabel": "Send"
}7.13 about_stats
Required:
stats[]length1..20, each{ value, label }
{
"stats": [
{ "value": "10K+", "label": "Products in Catalog" },
{ "value": "7", "label": "Warehouses Nationwide" }
]
}7.14 our_mission
Required:
title(string)paragraphs[](Tiptap list) length1..10
{
"title": "Our Mission",
"paragraphs": [
{ "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Make furnishing your home easy, affordable, and enjoyable." }] }] }
]
}8. End-to-End Flow Examples
8.1 Initial admin authoring flow
- Admin fetches page:
GET /api/admin/content/pages/home
- Page row is auto-created if missing.
- Admin writes each section with
PUT /sections/:sectionKey. - Frontend can consume from public endpoint immediately.
8.2 SEO attach/clear flow
Attach:
PATCH /api/admin/content/pages/home/metawith{"seoId": "<uuid>"}
Clear:
PATCH /api/admin/content/pages/home/metawith{"seoId": null}
8.3 Hide section flow
Update section:
PUT /api/admin/content/pages/home/sections/herowithisEnabled: false
Result:
- section still visible in admin page response
- section omitted from public/mobile response
9. Error Code Mapping
| errorCode | Typical HTTP | Cause |
|---|---|---|
CONTENT_NOT_FOUND | 404 | Missing page on public read, or missing page/section on delete |
CONTENT_SECTION_KEY_INVALID | 400 | Section key not allowed for selected page |
CONTENT_SECTION_PAYLOAD_INVALID | 400 | Payload schema invalid (Tiptap/URL/shape/constraints) |
CONTENT_SEO_NOT_FOUND | 400 | seoId not found in SEO table |
RATE_LIMIT_EXCEEDED | 429 | Admin endpoint throttling exceeded |
10. Request/Response Examples
10.1 Admin get page
GET /api/admin/content/pages/about
{
"message": "Content page fetched successfully",
"data": {
"id": 2,
"pageKey": "about",
"title": "About Page",
"seoId": null,
"seo": null,
"sections": [
{
"id": 14,
"sectionKey": "hero",
"position": 0,
"isEnabled": true,
"payloadJson": {
"heading": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "About Happy House" }] }] },
"description": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Our journey in building a better home shopping experience." }] }] }
},
"createdAt": "2026-07-23T12:00:00.000Z",
"updatedAt": "2026-07-23T12:00:00.000Z"
}
],
"createdAt": "2026-07-23T12:00:00.000Z",
"updatedAt": "2026-07-23T12:00:00.000Z"
}
}10.2 Public get page
GET /api/content/pages/about
{
"message": "Content page fetched successfully",
"data": {
"id": 2,
"pageKey": "about",
"title": "About Page",
"seoId": null,
"seo": null,
"sections": [
{
"id": 14,
"sectionKey": "hero",
"position": 0,
"isEnabled": true,
"payloadJson": {
"heading": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "About Happy House" }] }] },
"description": { "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Our journey in building a better home shopping experience." }] }] }
},
"createdAt": "2026-07-23T12:00:00.000Z",
"updatedAt": "2026-07-23T12:00:00.000Z"
}
],
"createdAt": "2026-07-23T12:00:00.000Z",
"updatedAt": "2026-07-23T12:00:00.000Z"
}
}10.3 Validation failure response
PUT /api/admin/content/pages/home/sections/hero with invalid payload:
{
"payloadJson": {
"heading": "plain-string-not-tiptap"
}
}Typical response:
{
"statusCode": 400,
"errorCode": "CONTENT_SECTION_PAYLOAD_INVALID",
"message": "Invalid payload for section 'hero': ..."
}11. Frontend Integration Notes
Recommended frontend pattern:
- Resolve page key by route.
- Call
/api/content/pages/:pageKey. - If
404 CONTENT_NOT_FOUND, load local fallback content. - Use runtime feature APIs for lists:
- FAQ API for FAQ items
- blog API for blog cards
- banner API for ad placements
- Render sections in backend-provided order.
12. Environment and Runtime Notes
No new environment variables are required by the Content module itself.
Dependencies:
- database availability
- permission seed consistency
- SEO table availability for metadata linking
13. API Release Checklist
- Admin role has
Content_READ/UPDATE/DELETEpermissions. - Client page keys match backend enums exactly.
- Admin panel sends valid Tiptap JSON docs.
- Link/image URLs satisfy safe URL validation.
- Public fallback behavior for
CONTENT_NOT_FOUNDis implemented. - Mobile app uses
/api/mobile/content/pages/:pageKeyroute where needed.
14. Endpoint Summary Table
| Method | Path | Permission | Notes |
|---|---|---|---|
GET | /api/admin/content/pages/:pageKey | Content_READ | Auto-create page if missing |
PATCH | /api/admin/content/pages/:pageKey/meta | Content_UPDATE | Updates title and/or seoId |
PUT | /api/admin/content/pages/:pageKey/sections/:sectionKey | Content_UPDATE | Upserts payload + position + visibility |
DELETE | /api/admin/content/pages/:pageKey/sections/:sectionKey | Content_DELETE | Deletes existing section row |
GET | /api/content/pages/:pageKey | Public | Enabled sections only |
GET | /api/mobile/content/pages/:pageKey | Public | Mobile-composed mirror |
15. Integration Diagram
Time fields in this module are stored as timezone-aware values and should be handled as ISO-8601 instants by API consumers.
See Also
- Feature Guide: See Content Module - Feature List for coverage map and usage matrix.
- Backend Guide: See Content Module - Backend Documentation for internals and migration details.