26 KiB
Cozsweet Private Room / Moments Frontend API
Last verified: 2026-07-08
This document is for the frontend integration of the Elio private room / Moments-like feed. The feature is backend-only in phase 1: posts come from existing schedule images, all posts are locked by default, and users spend credits to unlock the real image URLs.
1. Scope
The frontend needs to support three backend endpoints:
| Purpose | Method | Path |
|---|---|---|
| Get private-room config | GET |
/api/private-room/config |
| Get locked/unlocked Moments feed | GET |
/api/private-room/moments |
| Unlock one Moment post | POST |
/api/private-room/moments/{momentId}/unlock |
Recommended production base for cozsweet frontend:
https://api.cozsweet.com
Project production API base is also:
https://api.banlv-ai.com
Use the same API base already used by the cozsweet frontend login/chat client. Do not hardcode both in the same frontend flow.
2. Authentication
All three endpoints require the normal user Bearer token.
Authorization: Bearer <TOKEN>
If the token is missing or invalid, production returns:
{
"code": 401,
"message": "Not authenticated",
"success": false,
"data": null
}
Frontend action:
- If the user is a guest, use the existing guest token flow first.
- If the user is logged in, use the existing login token.
- If this API returns 401, refresh/re-login using the existing auth behavior.
3. Field Mapping vs Payment Plans
Do not treat private-room unlock price as a payment amount.
| Area | Price field | Currency field | Meaning |
|---|---|---|---|
| Payment plans | amountCents |
currency, for example PHP or USD |
Cash price in display cents. 14990 + PHP means PHP 149.90. |
| Payment plans | originalAmountCents |
currency |
Original crossed-out cash price. |
| Payment plans | dailyPriceCents |
currency |
VIP daily cash price, if applicable. |
| Private room | unlockCost |
currency: "credits" |
Total credits required to unlock this post. |
| Private room | unlockCostPerImage |
currency: "credits" |
Credits required per image. Currently 40. |
| Private room | requiredCredits |
currency: "credits" |
Same unlock requirement, useful for lock/paywall UI. |
Unlock state fields:
| Field | Type | Meaning | Frontend usage |
|---|---|---|---|
locked |
boolean | true means the post is locked. |
Primary field for locked UI. |
unlocked |
boolean | true means the post has been unlocked for this user. |
Primary field for unlocked UI if preferred. |
lockDetail.locked |
boolean | Same lock decision in chat-compatible structure. | Useful if sharing lock/paywall components with chat. |
images[].locked |
boolean | Whether this image placeholder is locked. | Use for image tile state. |
images[].url |
string or null | Real image URL only after unlock. | Render <img src={url}> only when not null. |
Recommended frontend condition:
const isLocked = item.locked || item.lockDetail?.locked;
4. Data Source and Image Access
Phase 1 data source:
| Source | Meaning |
|---|---|
| Table | elio_schedules |
| Single image field | image_url |
| Multi-image fields | image_urls, image_paths, metadata inside content |
| Returned post id | momentId, for example schedule:91 |
Locked posts deliberately do not expose real image URLs:
{
"images": [
{
"url": null,
"type": "image",
"locked": true,
"index": 0
}
]
}
After unlock, the same image item returns a real public Supabase Storage URL:
{
"images": [
{
"url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
"type": "image",
"locked": false,
"index": 0
}
]
}
Frontend can render it directly:
{image.url ? <img src={image.url} alt="" /> : <LockedImageTile />}
Production image accessibility was verified on 2026-07-08:
HTTP 200 OK
Content-Type: image/jpeg
Access-Control-Allow-Origin: *
So the frontend does not need an extra image proxy for the current Supabase public URLs.
5. Common Response Wrapper
All successful API calls use the standard wrapper:
interface ApiResponse<T> {
code: number;
message: string;
success: boolean;
data: T | null;
}
Important: unlock business failures can still return HTTP 200 with success: true, but data.unlocked is false. For unlock UI, check data.unlocked, data.locked, and data.reason, not only HTTP status.
6. GET /api/private-room/config
Purpose
Get the current user's private-room config, default unlock price, current credit balance, and Elio profile info.
Request URL
GET https://api.cozsweet.com/api/private-room/config
Request Format
No query params and no body.
Request Example
curl -X GET "https://api.cozsweet.com/api/private-room/config" \
-H "Authorization: Bearer <TOKEN>"
Success Response
{
"code": 200,
"message": "success",
"success": true,
"data": {
"currency": "credits",
"unlockCostDefault": 40,
"unlockCostPerImage": 40,
"source": "elio_schedules",
"creditBalance": 100,
"profile": {
"characterId": "elio",
"displayName": "Elio Silvestri",
"authorName": "Elio Silvestri",
"avatarUrl": null,
"coverUrl": null,
"title": "Elio Private room",
"subtitle": "Join me, unlock my private room"
}
}
}
Key Fields
| Field | Type | Meaning |
|---|---|---|
currency |
string | Always "credits" for this feature. |
unlockCostDefault |
number | Default single-image unlock price. Currently 40. |
unlockCostPerImage |
number | Price per image. Currently 40. |
creditBalance |
number | User's current credits. |
profile |
object | Elio private-room profile. |
Frontend Usage
Call it when entering the private room page if the frontend needs profile/header data before loading the feed. Otherwise, the feed endpoint also returns profile, creditBalance, and pricing fields.
7. GET /api/private-room/moments
Purpose
Get the private-room feed. Locked posts return placeholders and unlock prices; already-unlocked posts return real image URLs.
Request URL
GET https://api.cozsweet.com/api/private-room/moments?character=elio&limit=20
Query Parameters
| Field | Type | Required | Example | Meaning | Validation |
|---|---|---|---|---|---|
character |
string | No | elio |
Character/private-room id. Phase 1 supports elio. |
Defaults to elio. |
limit |
number | No | 20 |
Page size. | Min 1, max 50, defaults to 20. |
cursor |
string | No | 1783488000 |
Pagination cursor from previous data.nextCursor. |
Omit for first page. |
Request Example
curl -X GET "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=20" \
-H "Authorization: Bearer <TOKEN>"
Locked Post Response Example
{
"code": 200,
"message": "success",
"success": true,
"data": {
"profile": {
"characterId": "elio",
"displayName": "Elio Silvestri",
"authorName": "Elio Silvestri",
"avatarUrl": null,
"coverUrl": null,
"title": "Elio Private room",
"subtitle": "Join me, unlock my private room"
},
"items": [
{
"momentId": "schedule:91",
"source": "elio_schedules",
"sourceId": "91",
"characterId": "elio",
"author": {
"id": "elio",
"name": "Elio Silvestri",
"avatarUrl": null
},
"createdAt": "2026-07-01T08:11:35+00:00",
"publishedAt": "2026-07-01T08:11:35+00:00",
"timeText": "7天前",
"title": "Paris morning",
"content": null,
"text": null,
"textPreview": "Unlock to view private room photos",
"mediaCount": 1,
"images": [
{
"url": null,
"type": "image",
"locked": true,
"index": 0
}
],
"locked": true,
"unlocked": false,
"unlockCost": 40,
"unlockCostPerImage": 40,
"requiredCredits": 40,
"currency": "credits",
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "private_room_moment",
"hint": "40 credits · Unlock photo",
"actionLabel": "Unlock",
"type": "private_room_moment",
"requiredCredits": 40,
"currentCredits": 100,
"shortfallCredits": 0,
"mediaCount": 1,
"unlockCostPerImage": 40,
"detail": {
"type": "private_room_moment",
"requiredCredits": 40,
"currentCredits": 100,
"shortfallCredits": 0,
"mediaCount": 1,
"unlockCostPerImage": 40
}
}
}
],
"nextCursor": null,
"hasMore": false,
"creditBalance": 100,
"unlockCostDefault": 40,
"unlockCostPerImage": 40,
"currency": "credits",
"source": "elio_schedules"
}
}
Already-Unlocked Post Shape
When the user has already unlocked the post, the list endpoint returns real image URLs directly:
{
"momentId": "schedule:91",
"locked": false,
"unlocked": true,
"content": "A quiet morning in Paris.",
"text": "A quiet morning in Paris.",
"mediaCount": 1,
"images": [
{
"url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
"type": "image",
"locked": false,
"index": 0
}
],
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false
}
}
Key Item Fields
| Field | Type | Meaning |
|---|---|---|
momentId |
string | Post id. Use this for unlock. Example: schedule:91. |
source |
string | Current source table, elio_schedules. |
sourceId |
string | Schedule row id. |
title |
string | Optional post title. |
content |
string or null | Real text content. Null when locked. |
text |
string or null | Same as content, for frontend naming compatibility. |
textPreview |
string | Preview text shown while locked. |
mediaCount |
number | Number of images in this post. |
images |
array | Image placeholders when locked; real URLs when unlocked. |
locked |
boolean | Whether this post is locked. |
unlocked |
boolean | Whether this post is unlocked. |
unlockCost |
number | Total credits required for this post. |
unlockCostPerImage |
number | Credits per image. |
requiredCredits |
number | Required credits for lock UI. |
currency |
string | Always "credits". |
lockDetail |
object | Chat-compatible lock detail. |
Pagination
If data.hasMore is true, call the same endpoint with:
cursor=<data.nextCursor>
8. POST /api/private-room/moments/{momentId}/unlock
Purpose
Unlock one private-room post, deduct credits, persist the unlock state, and return real image URLs.
Request URL
momentId must be URL encoded because it contains :.
POST https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock
Frontend example:
const url = `/api/private-room/moments/${encodeURIComponent(momentId)}/unlock`;
Request Format
Content-Type:
Content-Type: application/json
Body is optional, but recommended:
{
"expectedCost": 40
}
Body Parameters
| Field | Type | Required | Example | Meaning |
|---|---|---|---|---|
expectedCost |
number | No | 40 |
The price shown to the user. If backend price changed, unlock returns reason: "cost_changed" instead of charging. |
Request Example
curl -X POST "https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock" \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"expectedCost":40}'
Unlock Success Response
{
"code": 200,
"message": "success",
"success": true,
"data": {
"momentId": "schedule:91",
"source": "elio_schedules",
"sourceId": "91",
"characterId": "elio",
"content": "A quiet morning in Paris.",
"text": "A quiet morning in Paris.",
"mediaCount": 1,
"images": [
{
"url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
"type": "image",
"locked": false,
"index": 0
}
],
"locked": false,
"unlocked": true,
"unlockCost": 40,
"unlockCostPerImage": 40,
"requiredCredits": 40,
"currency": "credits",
"reason": "ok",
"creditsCharged": 40,
"creditBalance": 60,
"previousCreditBalance": 100,
"persisted": true,
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"actionLabel": null,
"type": "private_room_moment",
"requiredCredits": 40,
"currentCredits": 60,
"shortfallCredits": 0,
"mediaCount": 1,
"unlockCostPerImage": 40
}
}
}
Already Unlocked Response
If the same user unlocks the same post again, backend does not charge again:
{
"code": 200,
"message": "success",
"success": true,
"data": {
"momentId": "schedule:91",
"locked": false,
"unlocked": true,
"reason": "already_unlocked",
"creditsCharged": 0,
"creditBalance": 60,
"images": [
{
"url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
"type": "image",
"locked": false,
"index": 0
}
]
}
}
Insufficient Credits Response
This is a business failure, but it still returns HTTP 200 and success: true.
{
"code": 200,
"message": "success",
"success": true,
"data": {
"momentId": "schedule:91",
"locked": true,
"unlocked": false,
"reason": "insufficient_credits",
"requiredCredits": 40,
"currentCredits": 10,
"shortfallCredits": 30,
"creditBalance": 10,
"images": [
{
"url": null,
"type": "image",
"locked": true,
"index": 0
}
],
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "insufficient_credits",
"hint": "Not enough credits to unlock this private room post.",
"actionLabel": "Unlock",
"requiredCredits": 40,
"currentCredits": 10,
"shortfallCredits": 30,
"mediaCount": 1,
"unlockCostPerImage": 40
}
}
}
Frontend action:
- Keep the post locked.
- Show recharge / insufficient credits UI.
- Do not try to render
images[].urlbecause it remains null.
Cost Changed Response
If expectedCost does not match the backend price:
{
"code": 200,
"message": "success",
"success": true,
"data": {
"momentId": "schedule:91",
"locked": true,
"unlocked": false,
"reason": "cost_changed",
"requiredCredits": 40,
"lockDetail": {
"locked": true,
"reason": "cost_changed",
"hint": "Unlock price changed. Please refresh and try again."
}
}
}
Frontend action:
- Refresh the feed item.
- Ask the user to confirm again with the latest
unlockCost.
Other Business Reasons
data.reason |
Meaning | Frontend action |
|---|---|---|
ok |
Unlock succeeded and credits were charged. | Show real images and refresh credit balance. |
already_unlocked |
User had unlocked before. No charge. | Show real images. |
insufficient_credits |
Not enough credits. No charge. | Keep locked and open recharge UI. |
cost_changed |
Frontend expected cost differs from backend. No charge. | Refresh and ask user to confirm again. |
not_found |
Post no longer exists. | Remove item or show unavailable state. |
no_media |
Post has no images to unlock. | Hide unlock button. |
deduct_failed |
Credit deduction failed. | Keep locked and allow retry. |
ok_persist_failed |
Credits charged and images returned, but unlock record fallback may have been used. | Show images; optionally refresh list. |
9. TypeScript Types
export interface PrivateRoomProfile {
characterId: string;
displayName: string;
authorName: string;
avatarUrl: string | null;
coverUrl: string | null;
title: string;
subtitle: string;
}
export interface PrivateRoomImage {
url: string | null;
type: "image";
locked: boolean;
index: number;
}
export interface PrivateRoomLockDetail {
locked: boolean;
showContent: boolean;
showUpgrade: boolean;
reason?: string | null;
hint?: string | null;
actionLabel?: string | null;
type?: "private_room_moment";
requiredCredits?: number;
currentCredits?: number | null;
shortfallCredits?: number;
mediaCount?: number;
unlockCostPerImage?: number;
detail?: {
type: "private_room_moment";
requiredCredits: number;
currentCredits: number | null;
shortfallCredits: number;
mediaCount: number;
unlockCostPerImage: number;
};
}
export interface PrivateRoomMoment {
momentId: string;
source: "elio_schedules";
sourceId: string;
characterId: string;
author: {
id: string;
name: string;
avatarUrl: string | null;
};
createdAt: string | null;
publishedAt: string | null;
timeText: string;
title: string;
content: string | null;
text: string | null;
textPreview: string;
mediaCount: number;
images: PrivateRoomImage[];
locked: boolean;
unlocked: boolean;
unlockCost: number;
unlockCostPerImage: number;
requiredCredits: number;
currency: "credits";
lockDetail: PrivateRoomLockDetail;
}
export interface PrivateRoomMomentsData {
profile: PrivateRoomProfile;
items: PrivateRoomMoment[];
nextCursor: string | null;
hasMore: boolean;
creditBalance: number;
unlockCostDefault: number;
unlockCostPerImage: number;
currency: "credits";
source: "elio_schedules";
}
export interface PrivateRoomUnlockData extends PrivateRoomMoment {
reason:
| "ok"
| "already_unlocked"
| "insufficient_credits"
| "cost_changed"
| "not_found"
| "no_media"
| "deduct_failed"
| "ok_persist_failed";
creditsCharged?: number;
creditBalance?: number;
previousCreditBalance?: number;
currentCredits?: number;
shortfallCredits?: number;
persisted?: boolean;
}
10. Recommended Frontend Flow
- User enters private room page.
- Call
GET /api/private-room/moments?character=elio&limit=20. - Render each item:
- If
item.locked === true, show locked image tiles and unlock price. - If
item.locked === false, renderimages[].url.
- If
- User taps unlock.
- Show confirm UI with
item.unlockCostanditem.currency. - Call:
await api.post(
`/api/private-room/moments/${encodeURIComponent(item.momentId)}/unlock`,
{ expectedCost: item.unlockCost },
);
- On response:
- If
data.unlocked === true, replace the current card with responsedata. - If
data.reason === "insufficient_credits", keep locked and open recharge UI. - If
data.reason === "cost_changed", refresh list and ask for confirmation again. - For other failure reasons, show a retry/unavailable message.
- If
- Update visible credit balance from
data.creditBalanceif present.
11. UI Rules
- Locked state must never render
images[].urlif it is null. - Do not show broken images for locked posts; use lock tiles based on
mediaCount. - Use
unlockCostfor the button price, notamountCents. - Use
currency === "credits"to display "credits", not PHP/USD. - For multi-image posts, display total price:
`${item.unlockCost} credits`
Optionally show per-image detail:
`${item.unlockCostPerImage} credits/photo`
- After successful unlock, do not call any separate image API. Use
data.images. - To avoid double tapping, disable the unlock button while the POST request is pending.
12. Failure Handling
| Case | How to detect | Frontend handling |
|---|---|---|
| Not logged in | HTTP 401 or wrapper code: 401 |
Refresh token, guest token, or login. |
| Validation error | HTTP 422 | Check path/body format, especially encoded momentId. |
| Insufficient credits | HTTP 200 with data.reason === "insufficient_credits" |
Open recharge UI. |
| Price changed | HTTP 200 with data.reason === "cost_changed" |
Refresh item and reconfirm. |
| Already unlocked | HTTP 200 with data.reason === "already_unlocked" |
Show returned images, no charge. |
| Deduct failed | HTTP 200 with data.reason === "deduct_failed" |
Keep locked and allow retry. |
| Network/API error | request failed or 5xx | Keep current state and show retry. |
13. Production-Safe Test Method
These tests do not require creating posts.
13.1 Auth Required
curl -i "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=1"
Expected:
HTTP/1.1 401 Unauthorized
13.2 Get Config
curl -X GET "https://api.cozsweet.com/api/private-room/config" \
-H "Authorization: Bearer <TOKEN>"
Expected:
data.currency === "credits"data.unlockCostPerImage === 40data.creditBalanceis a number
13.3 Get Locked Feed
curl -X GET "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=1" \
-H "Authorization: Bearer <TOKEN>"
Expected for a not-yet-unlocked post:
data.items[0].locked === truedata.items[0].unlocked === falsedata.items[0].unlockCost === data.items[0].mediaCount * 40data.items[0].images[0].url === null
13.4 Unlock With Insufficient Credits
Use a test/guest account with fewer credits than unlockCost.
curl -X POST "https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock" \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"expectedCost":40}'
Expected:
- HTTP 200
data.unlocked === falsedata.locked === truedata.reason === "insufficient_credits"data.images[0].url === null
13.5 Unlock Success
Use only a test account that intentionally has enough credits. This deducts real credits.
Expected:
data.unlocked === truedata.locked === falsedata.creditsCharged === data.unlockCostdata.images[0].urlis a non-empty HTTPS URL- Repeating the same unlock should return
reason: "already_unlocked"andcreditsCharged: 0
14. Confirmed Production Evidence
Verified on 2026-07-08:
GET https://api.cozsweet.com/health
HTTP 200
Private-room endpoints require auth:
GET /api/private-room/moments without Bearer token
HTTP 401 Not authenticated
Backend generated locked item shape:
{
"momentId": "schedule:91",
"mediaCount": 1,
"unlockCost": 40,
"unlockCostPerImage": 40,
"currency": "credits",
"locked": true,
"unlocked": false,
"requiredCredits": 40,
"images": [
{
"url": null,
"type": "image",
"locked": true,
"index": 0
}
],
"lockDetail": {
"locked": true,
"requiredCredits": 40
}
}
Backend generated unlocked image shape:
{
"momentId": "schedule:91",
"locked": false,
"unlocked": true,
"unlockCost": 40,
"currency": "credits",
"images": [
{
"url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
"type": "image",
"locked": false,
"index": 0
}
],
"lockDetail": {
"locked": false
}
}
The sample real image URL returned:
HTTP 200 OK
Content-Type: image/jpeg
Access-Control-Allow-Origin: *
15. Database / Persistence Notes
Migration file:
database/private-room-migration.sql
Full local path:
C:\windows_banlv\ai-boyfriend\ai-boyfriend-unified\database\private-room-migration.sql
Purpose:
- Create
private_room_unlocks - Persist
user_id + moment_key - Prevent charging again for already-unlocked posts
Current production backend also has a fallback to users.preferences.private_room_unlocked_moment_keys if the unlock table is missing, so the feature can work before/without the migration. Running the migration is still recommended for cleaner persistence and future analytics.
16. Frontend Integration Checklist
- Use Bearer token for all three endpoints.
- Encode
momentIdin unlock path withencodeURIComponent. - Render locked placeholders when
locked === true. - Render real images only when
images[].urlis not null. - Use
unlockCostandcurrency: "credits"for unlock price. - Send
{ expectedCost: item.unlockCost }when unlocking. - Treat
data.unlocked === falseas an unlock business failure even if HTTP is 200. - Update displayed credit balance from
data.creditBalance. - On insufficient credits, open recharge flow.
- On success, replace the card with returned
dataor reload the feed.
17. Rollback / Compatibility Notes
This document describes the currently deployed backend behavior. Frontend can integrate it without changing existing payment plan fields.
Compatibility:
- Existing payment
/api/payment/plansis unchanged. - Existing chat lock UI can reuse
lockDetail.locked,requiredCredits, andshortfallCredits. - Locked private-room items do not leak image URLs.
Rollback if frontend needs to disable the feature:
- Hide the private-room entry point.
- Do not call
/api/private-room/*. - No existing chat/payment flow needs to be rolled back.
18. Needs Confirmation
avatarUrlandcoverUrlcurrently returnnull; frontend should use local/default Elio assets until final profile images are provided.- Phase 1 content source is
elio_schedules; if product wants separate Moments content, backend can keep the API contract and change only the source implementation later.