Products
Creator product and media management.
This surface covers:
- paginated content library reads
- product creation from upload sessions
- legacy direct uploads
- sales-link metadata updates
- folder assignment
- collection membership
- media deletion
- product deletion
Product resource
Important fields returned by product endpoints:
| Field | Type | Notes |
|---|---|---|
id | integer | Product id. |
type | string | Product media type inferred from primary media. |
title | string or null | Product title. |
preview | string or null | Preview URL generated by backend storage resolution. |
preview_blurred | string or null | Blurred preview URL. |
price | integer | Price in minor units, for example cents. |
in_collection | boolean | Current collection membership flag. |
link | string or null | Public sales link. |
link_clicks | integer or null | Sales-link view count. |
unlocks | integer | Successful purchase count. |
total_earnings | integer | Total earnings in minor units. |
folder_id | string or null | Assigned content folder id. |
folder | object or null | { id, name }. |
media | array | Array of media resources. |
is_adult_content | boolean | AVS flag. |
is_verif_age | boolean | Buyer age-check flag. |
is_epoch_enabled | boolean | Creator/provider state. |
is_should_consent | boolean | Derived summary: true when any linked media requires third-party consent (media.is_should_consent). |
is_downloadable | boolean | Whether buyers can download. |
is_accessible | boolean | true when moderation + consent satisfy the purchasability matrix (same rules as server-side purchase checks). |
purchase_block_code | string or null | Why is_accessible is false: moderation_not_approved, manual_review_pending, consent_recipient_missing, consent_pending, consent_declined, consent_rescinded, price_review_pending, media_unavailable, ai_rail_unavailable. |
consent_status | string | Aggregated consent state across the product's media. |
consent_outstanding_count | integer | Consent slots still open. |
needs_price_review_approval | boolean | Price above the threshold, waiting for approval by Fangate. |
is_ai_content | boolean | true when at least one linked file is AI (ai_origin other than none). Sold on the AI domain; see AI content. |
ai_label | string or null | Buyer-facing disclosure of the first AI file: AI generated or AI modified. |
telegram_link | string or null | Deep link that opens this product's checkout in the Telegram Mini App (https://t.me/<bot>/<app>?startapp=<short link code>), usable as a URL button. One bot serves all products; AI products open their checkout on the AI domain through a second app of that bot, so use the value as returned instead of building it. No Telegram connection on the creator's account is needed. null when no bot is configured - fall back to link. |
private_description | string or null | Returned only to the product owner. |
public_description | string or null | Buyer-visible description. |
Media resource
Each media[] item currently includes:
| Field | Type | Notes |
|---|---|---|
id | integer | Media id. |
type | string | image, video, etc. |
preview | string | Preview URL. |
preview_blurred | string | Blurred preview URL. |
veriff_status | string | Human-readable moderation label such as Unchecked, Approved, AI Removal, Manual Approval, or Manual Removal. AI Removal means the automated checker removed the file - it says nothing about the file being AI-generated. |
removal_description | string or null | Admin/moderation reason if present. |
title | string or null | Media title from the library. |
ai_origin | string | none, synthetic, real_person_self, real_person_other (AI content). |
ai_label | string or null | AI generated, AI modified or null. |
is_should_consent | boolean | This file needs another person's consent. |
source | string or null | Original media URL, only exposed when the current user can update the product. |
Purchasability (moderation × consent)
Use is_accessible on each product resource for the matrix outcome (purchasable / not purchasable). For detail:
- Moderation — each
media[].veriff_status - Consent —
GET /api/products/{product}/consent-creator-tags(per-tagstatusandmedia_id) and derived productis_should_consent
Deleted products, blocked accounts, or missing media may still return 404 on fetch; is_accessible applies when the product is returned in list/detail responses.
See Consent & adult content — per upload status via API.
Product–media relationship
Products link to media through an n:m product_media pivot (product.media[] in API responses). One library asset can appear on multiple sales links without duplicating storage paths. Legacy media.product_id is kept in sync for backward compatibility.
Outbound webhook payloads expose purchased media as data.media_ids — always a string array of ids in the form m_{id} (see Webhooks — outbound delivery payload), even when a product has a single file.
See Media Library for standalone upload, bulk sessions, and attach/detach endpoints.
GET /api/products
Paginated content library endpoint for the authenticated creator.
- Auth required: Yes
- Roles: Authenticated creator
Query parameters
| Parameter | Type | Required | Notes |
|---|---|---|---|
page | integer | No | Page number. |
limit | integer | No | Items per page. |
sort_by | string | No | newest, most_earnings, most_views, collection. |
folder_id | integer | No | Filter to one folder. |
folder_state | string | No | Currently documented and implemented special value: unassigned. |
filterFolder | string | No | Legacy compatibility filter. Accepts folder id or unassigned. |
Success response example
{
"success": true,
"errors_message": null,
"data": {
"pages_total": 3,
"collection_link": "https://fangate.info/AbCdEf1234",
"data": [
{
"id": 5155,
"type": "image",
"title": "Campaign set",
"preview": "https://fangate.info/storage/users/7/products/preview.png",
"preview_blurred": "https://fangate.info/storage/users/7/products/preview-blurred.png",
"price": 4400,
"in_collection": false,
"link": "https://fangate.info/94w24rWk3v",
"link_clicks": 0,
"unlocks": 0,
"total_earnings": 0,
"folder_id": "12",
"folder": {
"id": "12",
"name": "Campaigns"
},
"media": [],
"is_adult_content": true,
"is_verif_age": false,
"is_epoch_enabled": true,
"is_should_consent": false,
"is_downloadable": true,
"private_description": null,
"public_description": ""
}
]
}
}Pagination and count behavior
Current backend returns:
pages_totalcollection_link- current page results in
data.data
Current backend does not expose separate totalCount, allItemsCount, or folderCounts fields on this API response.
If frontend clients need those additional totals, they must compute them elsewhere or wait for a dedicated backend extension.
GET /api/products/collection
Return the creator's products that are currently part of the shareable collection.
- Auth required: Yes
- Roles: Authenticated creator
- Query params:
page,limit
Response shape matches GET /api/products.
GET /api/products/
Return a single product visible to the authenticated creator.
- Auth required: Yes (generated API keys: read scope, always on)
- Roles: Product owner / authorized creator
- Rate limit (generated keys only): 60 requests per minute per key and IP, plus 120 per minute per IP across all keys - one counter shared with the product list. Creator login tokens are not limited here.
Path parameters
| Parameter | Type | Required |
|---|---|---|
product | integer | Yes |
Success response example
{
"success": true,
"errors_message": null,
"data": {
"id": 5155,
"type": "video",
"title": "My Product",
"preview": "https://...",
"preview_blurred": "https://...",
"price": 4400,
"in_collection": false,
"link": "https://fangate.info/94w24rWk3v",
"link_clicks": 0,
"unlocks": 0,
"total_earnings": 0,
"folder_id": null,
"folder": null,
"media": [
{
"id": 7792,
"type": "video",
"preview": "https://...",
"preview_blurred": "https://...",
"veriff_status": "Manual Approval",
"removal_description": null,
"source": "https://..."
}
],
"is_adult_content": true,
"is_verif_age": false,
"is_epoch_enabled": true,
"is_should_consent": false,
"is_downloadable": true,
"private_description": null,
"public_description": ""
}
}POST /api/products
Create a new product or attach media to an existing draft.
- Auth required: Yes
- Roles: Authenticated creator
- Content type:
multipart/form-data
There are three active creation flows.
Library media flow
Link existing approved library items (see Media Library):
| Field | Type | Required | Notes |
|---|---|---|---|
media_ids | integer[] | Yes for this flow | Media ids owned by the current creator. |
price | integer | Yes when creating a new product | Price in minor units. |
title | string | No | Optional. |
is_adult_content | boolean | No | Defaults from user upload_defaults or profile settings. |
is_verif_age | boolean | No | Defaults from user upload_defaults or profile settings. |
is_downloadable | boolean | No | Absent means false on this call - send it explicitly. |
is_should_consent | boolean | No | Defaults to false. |
per_media_consent | boolean | Yes, with media_consents | Without per_media_consent: true the media_consents list is silently ignored. |
media_consents[], consent_date | - | No | Declare consent for specific files in the same call: "per_media_consent": true, "consent_date": "2026-09-12", "media_consents": [{ "media_id": 101, "tagged_creator_ids": [55] }]. |
private_description | string | No | Creator-only notes. |
public_description | string | No | Buyer-visible description. |
No ai_origin on this flow: library files are classified already. A product with at least one AI file becomes an AI product (is_ai_content: true).
{
"media_ids": [101, 102, 103],
"price": 1500,
"title": "Summer Bundle",
"is_adult_content": true,
"is_downloadable": false
}Preferred flow: upload sessions
Send a previously finalized upload session:
| Field | Type | Required | Notes |
|---|---|---|---|
upload_session_id | string | Yes for this flow | Session must belong to current user and be ready to attach. |
price | integer | Yes when creating a new product | Price in minor units. |
product_id | integer | No | Attach to an existing product. |
title | string | No | Optional. |
is_adult_content | boolean | No | Defaults from user settings if omitted. |
is_verif_age | boolean | No | Defaults from user settings if omitted. |
is_downloadable | boolean | No | Ignored on this path - the product takes the account's upload default. Change the flag afterwards with PATCH /api/products/{product}. |
is_should_consent | boolean | No | Defaults to false. |
ai_origin | string | See note | The new file's AI classification (none, synthetic, real_person_self, real_person_other). Required for generated keys with upload_ai_content unless the session already carries it; a value here wins over the session's. See AI content. |
ai_attested | boolean | With AI values | Must be true for every value other than none - unless the session already carries the attestation; then it may be omitted here even when ai_origin is repeated. An explicit false is refused. |
private_description | string | No | Creator-only notes. |
public_description | string | No | Buyer-visible description. |
Responds 200 with the product resource (201 on the library flow).
Legacy flow: direct multipart upload
Send a new file directly:
| Field | Type | Required | Notes |
|---|---|---|---|
media | file | Yes for this flow | Legacy direct upload. |
price | integer | Yes when creating a new product | Minimum current rule is 500 minor units. |
product_id | integer | No | Attach to an existing draft product. |
title | string | No | Optional. |
extension | string | No | Legacy helper. |
is_adult_content | boolean | No | Optional. |
is_verif_age | boolean | No | Optional. |
is_should_consent | boolean | No | Optional. |
ai_origin, ai_attested | string, boolean | See session flow | Same rule as the session flow. There is no session here, so the values go with the request - on every chunk of a chunked upload. |
private_description | string | No | Optional. |
public_description | string | No | Optional. |
A single request is limited to 20 MB (larger files only as chunked uploads). Files above 10 MB are processed in the background and answered with 202; the AI classification and its attestation are validated before that, so a missing value never disappears into the background job.
Important validation and side effects
- If
product_idis present, product ownership is enforced. - Large direct uploads are accepted and queued asynchronously via
CreateProductJob. - If upload sessions are used, attachment is handled by
UploadAttachmentService. - After product creation, moderation dispatch runs when either:
- creator has
skip_moderation, or - SightEngine moderation is enabled
- creator has
Success / accepted responses
Immediate success:
{
"success": true,
"errors_message": null,
"data": {
"id": 5155,
"link": "https://fangate.info/94w24rWk3v"
}
}Async accepted for larger direct uploads:
{
"success": true,
"errors_message": null,
"data": {
"id": 5155
}
}Error examples
Chunk still uploading:
{
"success": true,
"errors_message": null,
"data": {
"upload_percentage": 64
}
}Media processing failure:
{
"success": false,
"errors_message": "Media service error",
"data": null
}File too large for legacy path:
{
"success": false,
"errors_message": "File size too large",
"data": null
}PATCH /api/products/
Update product metadata.
- Auth required: Yes
- Roles: Product owner
- Content type:
application/json
Request body
| Field | Type | Required |
|---|---|---|
title | string | No |
is_adult_content | boolean | No |
is_verif_age | boolean | No |
is_should_consent | boolean | No |
is_downloadable | boolean | No |
private_description | string | No |
public_description | string | No |
Important notes
- This endpoint updates metadata only.
- It does not replace media.
- It does not update the price.
Price Editing
Creators can change the price of an existing sales link in two explicit ways:
- update the current link's price
- create a new link with a different price while keeping the original link unchanged
This split is intentional and avoids ambiguous behavior when a link already has traffic or active checkout sessions.
Data model and scope
- A product remains the media/content entity.
- URL links are stored separately in
short_links, but current checkout price is still sourced fromproducts.price. - "Create new link with different price" is implemented by duplicating the product row (same media references, new product id, new short link).
- Title and descriptions can be overridden in the duplicate flow; if omitted, source values are kept.
Checkout and validation behavior
- Price must be revalidated server-side at checkout start.
- Sessions that already started checkout before a price update keep their originally locked price.
- A later price update does not force-invalidate those already pending checkout sessions.
Analytics behavior
- Analytics are tracked per link.
- Future events use the link/price active at event time.
- Historical statistics remain based on prior prices and are not retroactively recomputed.
Concurrency and compatibility
- Standard optimistic concurrency (last successful write wins) is acceptable for this ticket.
- Existing clients keep current behavior until they adopt the dedicated price-edit endpoints below.
PATCH /api/products/{product}/price
Update the price on the existing active sales link for a product.
- Auth required: Yes
- Roles: Product owner
- Content type:
application/json
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
price | integer | Yes | New price in minor units. |
Example request
{
"price": 1000
}Success response example
{
"success": true,
"errors_message": null,
"data": {
"id": 5155,
"link": "https://fangate.info/94w24rWk3v",
"price": 1000
}
}Notes
- Applies only to the existing link associated with the product.
- Does not create a new link id/slug.
- New checkout sessions use the updated price after server-side revalidation.
POST /api/products/{product}/price-links
Create a new priced link by duplicating the current product and assigning a new short link. Media rows are reused through the product_media pivot (no file duplication).
- Auth required: Yes
- Roles: Product owner
- Content type:
application/json - Success status:
201 Created
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
price | integer | Yes | Price in minor units for the new link. |
title | string | No | Optional new link display title override. |
private_description | string | No | Optional creator-facing note override. |
public_description | string | No | Optional buyer-facing description override. |
Example request
{
"price": 1200,
"title": "Campaign set - promo",
"public_description": "Limited drop price"
}Success response example
{
"success": true,
"errors_message": null,
"data": {
"id": 6201,
"title": "Campaign set - promo",
"link": "https://fangate.info/AbCdEf1234",
"price": 1200
}
}Notes
- Creates a new product record with a new short link and new price.
- Existing source product and link continue to work at their original price.
- Media rows are duplicated without re-upload; storage files are shared until one side is no longer referenced.
- Existing in-flight checkouts on older links continue with their original locked price.
PATCH /api/products/{product}/folder
Assign or unassign one product to a content folder.
- Auth required: Yes
- Roles: Product owner
- Content type:
application/json
Request body
Assign:
{
"folder_id": 12
}Unassign:
{
"folder_id": null
}Validation
- if a folder id is provided, it must belong to the authenticated creator
nullis valid and removes folder assignment
POST /api/products/{product}/collection
Toggle collection membership for a product.
- Auth required: Yes
- Roles: Product owner
Side effects
- if the creator does not yet have a collection, backend creates one
- if the collection has no short link, backend creates that too
- calling again removes the product from the collection
DELETE /api/products/media/
Delete one media row and remove its stored files.
- Auth required: Yes
- Roles: Media owner / authorized product owner
Side effects
- deletes media DB row
- deletes
media,preview,preview_blurred, andthumbnailpaths only when not referenced by any other media rows - returns the parent product resource
DELETE /api/products/
Delete a product.
- Auth required: Yes
- Roles: Product owner
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
delete_media | boolean | true | Also delete the product's files from the vault (only files no other product uses). Send delete_media=0 to keep them in the library. |
Side effects
- deletes related media rows unless
delete_media=0; storage objects are deleted only when no other media rows reference the same paths - deletes related consent records and consent requests
- deletes the short link
- deletes the product
Success response example
{
"success": true,
"errors_message": null,
"data": "deleted"
}Sales-link flow example
- upload media through Upload Sessions or legacy direct upload
POST /api/products- optional
PATCH /api/products/{product}to update descriptions or toggles - optional
POST /api/products/{product}/collection - frontend reads
linkfrom the product resource and exposes the sales link
Price rules
- price is stored in minor units
- minimum current validation is
500 - backend validation currently enforces integer pricing rather than decimal strings
POST /api/products/{product}/media
Attach existing library media to a product without re-uploading.
- Auth required: Yes
- Roles: Product owner
- Content type:
application/json
{
"media_ids": [101, 102]
}DELETE /api/products/{product}/media/
Detach media from a product. The media item remains in the creator library.
- Auth required: Yes
- Roles: Product owner