Products
Creator product and media management.
This surface covers:
- paginated content library reads
- product creation from upload sessions
- 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. |
buyer_price | integer | What the buyer pays at checkout, in minor units of the same currency: price plus the buyer fee, plus the age-check share on a product with is_verif_age. The number Fangate's Telegram card and the link preview show. A buyer whose country requires an age check on a product without it pays that share on top - see GET /api/fees/preview. |
in_collection | boolean | Current collection membership flag. |
link | string or null | Public sales link. Append ?ref= for your own reference per 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 | Always true: every product is AVS+ (18+ notice, age check for buyers in countries that require one). |
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, sales_stopped, removed_by_fangate - what each means and what ends it: Error codes. |
consent_status | string | Aggregated consent state across the product's media. |
consent_outstanding_count | integer | Consent slots still open. |
needs_price_review_approval | boolean | Part of Fangate's compliance programme: a product priced above the review threshold is created normally but waits for manual approval by Fangate before it can be bought (purchase_block_code: price_review_pending meanwhile). The threshold is published in GET /api/app-data as products.manual_price_review_threshold_cents - currently 15000 (150.00). |
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. Your own reference goes after the start parameter as --<reference>, see Your own reference per link. |
private_description | string or null | Returned only to the product owner. |
client_reference | string or null | Your own reference for the product (order number, internal id), up to 64 characters. Returned only to the product owner and echoed in every webhook payload as data.client_reference. |
show_creator_name | boolean or null | Owner only. true: the item page prints the account's stage name next to its header - Exclusive content from <stage name> - provided the account chose a stage name itself. false (the default): plain Exclusive content. |
sales_stopped | boolean or null | Owner only. true while the product is taken off sale with the switch of PATCH /api/products/{product_id}. |
removed_by_fangate | boolean or null | Owner only. true while Fangate has taken the product off sale (purchase_block_code: removed_by_fangate). The files are untouched; neither sales_stopped nor sales_end_at lifts it - contact Fangate. |
sales_end_at | string or null | Owner only. ISO 8601 moment from which the product is off sale, or null. |
payment_options | object or null | Owner only. The checkout tiles, tip button and smallest tip that apply to this product, plus which of them still follow the account - see Payment options. |
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. |
duration_seconds | number or null | Video length in seconds (videos only; null for images and until the file has been processed). |
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.
Payment options: checkout tiles and tips
A creator decides what a buyer sees at the checkout: which payment tiles are shown (paypal, card = "Credit Card / Other", crypto - see what each tile offers), whether the Send a tip button is offered on the product's pages, and the smallest tip. The account holds the value (dashboard: Settings → Payment options for customers); a product follows the account until it carries its own value - which is what payment_options on POST and PATCH /api/products sets.
{
"payment_options": {
"tiles": { "paypal": true, "card": true, "crypto": false },
"tips_enabled": false,
"min_tip_cents": 500
}
}| Field | Type | Notes |
|---|---|---|
tiles | object or null | true = the tile is shown. Send only the tiles you want to change; null = follow the account again. |
tips_enabled | boolean or null | The tip button on this product's pages. null = follow the account. |
min_tip_cents | integer or null | Smallest tip from this product's pages, minor units, within the platform range (500 - 30000) and a whole amount (a multiple of 100). null = follow the account. |
- Hiding a tile only hides it. The payment method itself stays active - a buyer who reaches the payment provider can still pick another method there.
- At least one tile has to stay shown (
422otherwise). If none of the shown tiles is available for a product - for example onlycryptois left and the account cannot take crypto - the checkout shows all available tiles, so a product always stays buyable. The fallback offered after a declined card payment is always shown. - On
POSTthe field is stored by the call that creates the product; a call that only attaches a further file (product_id) does not change it. Without the field the product follows the account."payment_options": nullonPATCHresets all three. - Tips: switching them off removes the button from the product's pages; the creator's own tip page is not affected. The smallest tip is enforced by the server.
- A price link created from the product (
POST /api/products/{product}/price-links) keeps the product's own payment options. - In a
multipart/form-datarequest send the booleans ofpayment_optionsas1/0- the stringstrue/falseare refused there. JSON requests use real booleans.
The product resource returns the values that apply (owner only), with follows_account telling which of them are the account's:
{
"payment_options": {
"tiles": { "paypal": true, "card": true, "crypto": false },
"tips_enabled": true,
"min_tip_cents": 500,
"follows_account": { "tiles": false, "tips_enabled": true, "min_tip_cents": true }
}
}The account-wide values (GET / PATCH /api/user/payment-options) are an account setting and need the creator's login token; a generated API key sets the options per product.
Posting a product card from your own Telegram bot
Fangate's bot posts product cards for creators itself (inline search, and the share button inside Fangate's Mini App). That share button works only inside Fangate's Mini App with the creator's own Telegram account - another bot cannot call it. Your bot posts the card with an ordinary Bot API message, in one of two forms: as text with a link preview, built from GET /api/products/{product} (below), or as a photo with Fangate's card picture. The inline search of Fangate's bot sends the first form (see below).
- Wait until the product can be sold -
product.sellableoris_accessible: true. A link Telegram previews while the product is still in review shows a page without title and image, and Telegram keeps that preview for the link. - The text:
titleand the price the buyer pays (buyer_price, formatted in the product's currency), for exampleBeach set — €6.40. Putlinkon the title as atext_linkentity and ask for the preview above the text. Telegram builds the preview from the link: the blurred teaser image and one line with the stage name (whenshow_creator_nameis on) and the same price. - One button: a URL button with
telegram_link, for exampleUnlock (€6.40). It opens the checkout in Fangate's Mini App; payment and delivery run there. Wheretelegram_linkisnull, uselink.
{
"chat_id": 123456789,
"text": "Beach set — €6.40",
"entities": [{ "type": "text_link", "offset": 0, "length": 9, "url": "https://fangate.info/94w24rWk3v" }],
"link_preview_options": { "show_above_text": true },
"reply_markup": { "inline_keyboard": [[{ "text": "Unlock (€6.40)", "url": "https://t.me/Fngatebot/vip?startapp=94w24rWk3v" }]] }
}length counts UTF-16 code units, as the Bot API does. Telegram fetches a link's preview once and keeps it; after a price change it may still show the old price for a link it already fetched. Add a query parameter of your own that changes with the price - for example ?v=640 - and Telegram fetches the page again; the sales page ignores it.
As a photo with Fangate's card picture
Your bot can also send the card as a photo with Fangate's card picture: the locked preview with the lock burned in and one line on top, From <stage name>: <title> or the title alone. GET /api/products/{product}/telegram-card returns the picture's url and the button text with the price the fan pays. Send it with sendPhoto and one URL button: button as its text, telegram_link as its url. A caption is optional.
{
"chat_id": 123456789,
"photo": "<url from GET /api/products/{product}/telegram-card>",
"reply_markup": { "inline_keyboard": [[{ "text": "Unlock 5 photos ($21.77)", "url": "https://t.me/Fngatebot/vip?startapp=94w24rWk3v" }]] }
}The url is signed for two hours: send the photo right away, or call the endpoint again. Telegram stores the picture when the message is sent, so a sent card keeps its image. Unlike the link preview, the photo does not depend on Telegram's preview cache; after a price change, fetch the card again for the new button text.
Where your bot can send it. In every chat your bot can write to - and into the creator's own private chats as the account's Telegram Business chatbot: with the business_connection_id of that connection, the card goes out as a message of the creator's account, without a "via" line. Telegram allows this only for an account with Telegram Premium, in private chats with an incoming message in the last 24 hours; URL buttons are allowed there. Both forms of the card work this way.
From the creator's own Telegram account
Only bots can attach buttons to a message - a regular Telegram account cannot. A client signed in as the creator therefore sends the card as a bot's inline result, and Telegram marks the message "via @<bot>". To send it from the creator's account without that mark, let your bot send it as the account's Business chatbot (see above).
An integration that works inside the creator's own Telegram account - a Telegram client signed in as the creator, not a bot - can send the exact card Fangate's bot builds. Ask Fangate's bot (the one named in telegram_link) for the product with an inline query of its id - messages.getInlineBotResults with the query 11779 - and send the result into the chat with messages.sendInlineBotResult. A person does the same by typing @<bot> 11779 in a chat. The query finds the creator's products by id or title. It needs the creator's Telegram account connected to Fangate (dashboard: Settings → Telegram mini app) and a product that can be sold.
As a photo. Ask with crm:<product id>:<key> instead of the id - key from GET /api/products/{product}/telegram-card, called right before - and the bot answers with Fangate's card picture as a photo with the same button. Text after a space becomes the photo's caption, for example crm:11779:07b96184a9c6a251 Just shot this for you; an inline query holds 256 characters in all. # and a reference right after the key - crm:11779:07b96184a9c6a251#chatter-anna Just shot this for you - puts your own reference on the card's button. Telegram marks this card "via @<bot>" as well. If the bot finds no picture for the key, it answers with the link-preview card, your text on top. If it answers with no result at all (account not connected, product not for sale), send the sales link as text instead.
Why POST /api/user/telegram/miniapp/share stays closed to API keys. It prepares a card that only Fangate's Mini App, open in the creator's own Telegram, can send. Another bot cannot send it at all, and a client in the creator's account gets the identical card through the inline query above without it. Opening it to keys would add nothing, so it stays login-only.
Your own reference per link
Add a reference of your own to a product's link, and Fangate keeps it with the sale and returns it unchanged as link_reference in the payment webhooks - for example to tell which campaign, chat or chatter a sale came from. Product and price stay the same; one link per recipient is all it takes.
| Link | How | Example |
|---|---|---|
Sales link (link) | Query parameter ref | https://fangate.info/94w24rWk3v?ref=chatter-anna |
Telegram link (telegram_link) | -- and the reference after the start parameter | https://t.me/<bot>/<app>?startapp=94w24rWk3v--chatter-anna |
| Card from the creator's own account | # and the reference right after the key of the crm: query | crm:11779:07b96184a9c6a251#chatter-anna |
- Format: 1 to 64 characters - letters, digits,
_and-, but no--(the Telegram link uses it to separate the reference). On the sales link a reference outside these rules is ignored: the purchase goes through withlink_reference: null. A Telegram link with such a reference does not open the checkout; the Mini App opens Fangate's website instead. - Every payment method of the checkout page keeps the reference.
- On a card, the button carries it - the button that opens the Mini App, not the sales link in the text of a link-preview card.
- Visible to the fan. The reference is part of the address the fan opens; the fan can read, change or remove it, and Fangate does not check it. Put no personal data or secrets into it, and use it to attribute sales, not as proof.
- Only the webhooks return it: every payment event (
payment.*,refund_request.rejected) carrieslink_reference,nullwhen the purchase carried none. Wallet rows and the product resource do not show it.
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. An agency's key working inside its accounts has a higher ceiling per IP - see Rate limits.
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": ""
}
}GET /api/products/{product}/telegram-card
Fangate's card picture and the button text for a product's Telegram card that a bot of your own sends as a photo. How to send it: As a photo with Fangate's card picture.
- Auth required: Yes (generated API keys:
api-key:urls.read) - Roles: Product owner / authorized creator; an agency's key names the account with
X-Fangate-Account - Rate limit (generated keys): one counter with
GET /api/products/{product}
Parameters
| Parameter | In | Type | Required | Meaning |
|---|---|---|---|---|
product | path | integer | Yes | |
stage_name | query | string, up to 80 characters | No | The name in the picture's line: From <stage name>: <title>. Empty or absent: the title alone. Emoji and other symbols the font cannot draw are left out of the line. |
Success response example
{
"success": true,
"errors_message": null,
"data": {
"url": "https://...",
"key": "07b96184a9c6a251",
"button": "Unlock 5 photos ($21.77)",
"line": "From Mia: Sunset set"
}
}| Field | Meaning |
|---|---|
url | The card picture: the locked preview as JPEG, up to 1,200 px on the long edge, with the lock and the line burned in. Signed for two hours. |
button | The unlock text with the price the fan pays (price plus buyer fee), for the URL button next to telegram_link. |
line | The line drawn into the picture. |
key | For the inline query crm:<product id>:<key> from the creator's own account (see From the creator's own Telegram account); a bot of your own does not need it. |
Errors
| HTTP | errors_code | When |
|---|---|---|
403 | Not a product of this account. | |
409 | card_picture_unavailable | The picture cannot be made yet - usually right after the upload, before the product's blurred preview exists. Try again later, or wait for product.sellable. |
POST /api/products
Create a new product or attach media to an existing draft.
- Auth required: Yes
- Roles: Authenticated creator
- Content type:
application/jsonormultipart/form-data
There are two creation flows: vault files (media_ids) and upload sessions (upload_session_id).
Price floor and review threshold. price is in minor units of the creator's currency and must be at least 500 (5.00); a lower price answers 422 with errors_code: price_below_minimum. Read the floor from GET /api/app-data (products.min_price_cents) instead of hardcoding it.
Payment limits. The API sets no upper limit on price, but not every payment method takes every amount. PayPal is offered up to a price of 250.00 USD or 224.00 EUR. Card payments are set up for a price of up to 440.00 USD, 395.00 EUR or 200.00 GBP; the card processor's limit applies to the total the buyer pays, including the buyer fee. Above that a buyer can only pay with crypto. Fangate's own app accepts prices up to these card limits - keep to them unless your buyers pay with crypto. The limits can change.
Text minimum: min. 15 characters
Announced - in force from 27 September 2026 (changelog). A sales page needs words: at least 15 characters in title or in public_description - either one is enough, spaces at the ends do not count. From 27 September 2026 a call without that answers 422 with errors_code: product_text_too_short (reported on public_description). A call that only attaches a further file to an existing product (product_id) is never refused for this. Send a title or a description of that length today and nothing changes for you. Prices above the compliance-review threshold (products.manual_price_review_threshold_cents, currently 15000) are accepted, but the product waits for Fangate's manual approval before it can be bought (needs_price_review_approval, purchase_block_code: price_review_pending).
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 on its own; min. 15 characters in title or public_description (one of the two) from 27 September 2026 - see Text minimum. |
is_adult_content | boolean | No | Still accepted, but ignored: every product is AVS+, the value is stored and answered as true. |
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. |
client_reference | string | No | Your own reference for this product, up to 64 characters: starts with a letter or digit, then letters, digits and . _ : - (a UUID fits). Stored by the call that creates the product, returned in the product and in every webhook payload (data.client_reference). Not unique, never shown to buyers. |
show_creator_name | boolean | No | true prints the account's stage name next to the header of the item page: Exclusive content from <stage name>. Off by default. The name appears only when the account chose a stage name itself - the default every account starts with (Creator plus a number) is never printed, and the header stays Exclusive content. An AI model carries the name its agency gave it. Stored by the call that creates the product; a call that only attaches a further file (product_id) does not change it - use PATCH. |
public_description | string | No | Buyer-visible description. Min. 15 characters here or in title (one of the two) from 27 September 2026 - see Text minimum. |
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 on its own; min. 15 characters in title or public_description (one of the two) from 27 September 2026 - see Text minimum. |
is_adult_content | boolean | No | Still accepted, but ignored: every product is AVS+, the value is stored and answered as true. |
is_verif_age | boolean | No | Defaults from user settings if omitted. |
is_downloadable | boolean | No | true = the buyer may download the file, false = view-only in the browser (videos are re-encoded for playback). Honoured like on the other flows; without it the product takes the account's upload default (view-only when none is stored). Applies to the call that creates the product - attaching a further file with product_id does not change it. |
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. |
client_reference | string | No | Your own reference for this product, up to 64 characters: starts with a letter or digit, then letters, digits and . _ : - (a UUID fits). Stored by the call that creates the product, returned in the product and in every webhook payload (data.client_reference). Not unique, never shown to buyers. |
show_creator_name | boolean | No | true prints the account's stage name next to the header of the item page: Exclusive content from <stage name>. Off by default. The name appears only when the account chose a stage name itself - the default every account starts with (Creator plus a number) is never printed, and the header stays Exclusive content. An AI model carries the name its agency gave it. Stored by the call that creates the product; a call that only attaches a further file (product_id) does not change it - use PATCH. |
public_description | string | No | Buyer-visible description. Min. 15 characters here or in title (one of the two) from 27 September 2026 - see Text minimum. |
Responds 200 with the product resource (201 on the library flow).
A product built over several calls. On this flow one call attaches one file: the first call creates the product, every further call carries product_id. A product can be bought as soon as one of its files is approved - files still in moderation do not block it, buyers receive the approved files and the rest once approved. Consent is different: one file with open consent blocks the whole product. So if a file that needs another person's consent follows in a later call, send is_should_consent: true together with per_media_consent: true on the creating call. That keeps the product blocked (purchase_block_code: consent_recipient_missing) until the declared file has arrived with its consent_tagged_creator_ids; without it the product can turn sellable - and be reported by the product.sellable webhook - before that file exists, and is blocked again the moment you attach it.
Important validation and side effects
- If
product_idis present, product ownership is enforced. - If upload sessions are used, attachment is handled by
UploadAttachmentService. - After product creation the content moderation of the new files is queued.
- From 27 September 2026: at least 15 characters in
titleorpublic_description(see the text minimum above); otherwise422,errors_code: product_text_too_short. Not on a call that carriesproduct_id.
Success / accepted responses
Immediate success:
{
"success": true,
"errors_message": null,
"data": {
"id": 5155,
"link": "https://fangate.info/94w24rWk3v"
}
}Error examples
A call with an API key that brings neither upload_session_id nor media_ids - for example a file sent directly - is refused before any other check (integrations upload through upload sessions):
{
"success": false,
"errors_message": "Direct file upload is not available with an API key. Upload through an upload session (POST /api/upload-sessions) and create the product with upload_session_id.",
"errors_code": "forbidden",
"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 |
client_reference | string or null | No |
show_creator_name | boolean | No |
payment_options | object or null | No |
public_description | string | No |
sales_stopped | boolean | No |
sales_end_at | string (ISO 8601) or null | No |
Important notes
sales_stopped: truetakes the product off sale at once,falselifts the switch (an end date that has passed keeps the product off sale until you clear it withnull).sales_end_atdoes the same from a moment you choose, ISO 8601 with a time zone offset orZ; it must lie in the future (otherwise422), andnullremoves it. While either applies, the sales link and the Telegram link refuse new purchases and the product reportsis_accessible: falsewithpurchase_block_code: sales_stopped. Buyers who already bought keep their access. Every product is switched on its own - a price link of the same content is not affected. Subscribers ofproduct.blockedreceive it withreason: sales_stoppedonce per stop; for an end date within five minutes of the moment passing. Putting a product back on sale sends noproduct.sellable; a block that still applies (a rejected file, a missing consent) is reported withproduct.blockedat that moment - also when it had been reported before the stop.client_referencesets, changes or clears (null) your own reference for the product; same format as onPOST /api/products.show_creator_nameswitches the stage name next to the item page's header on or off (Exclusive content from <stage name>); same rules as onPOST /api/products. A call that does not name it leaves it as it is.payment_optionssets the checkout tiles, the tip button and the smallest tip of this product - see Payment options.nullmakes the product follow the account again.- From 27 September 2026: a call that changes
titleorpublic_descriptionmust leave the product with at least 15 characters in one of the two - the value after the change counts, and a field the call does not name keeps its current value. Otherwise422witherrors_code: product_text_too_short. A call that names neither field is never refused for this: an older product without any text keeps taking price and setting changes. - 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; at least 500 (422, errors_code: price_below_minimum otherwise). Above the compliance-review threshold the product goes back into manual price review. |
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. Min. 15 characters in title or public_description of the new link - the override or the inherited value - from 27 September 2026. |
private_description | string | No | Optional creator-facing note override. |
public_description | string | No | Optional buyer-facing description override. Min. 15 characters here or in title of the new link - the override or the inherited value - from 27 September 2026. |
client_reference | string or null | No | Your own reference for the new link. Omitted: copied from the source product, so a sale through the price link still carries it in the webhook. null: none. |
show_creator_name | - | - | Not a field of this call: the new link copies it from the source product, so both pages carry the same header. Change it with PATCH on the new product. |
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.
- From 27 September 2026: the new link needs at least 15 characters in its
titleorpublic_description- its own override or, where the call names none, the source product's value. Otherwise422witherrors_code: product_text_too_short; a source product without any text needs a title or a description in the call.
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 | false with an API key, true with a login token | Also delete the product's files from the vault (only files no other product uses). With an API key the files stay in the vault unless you send delete_media=1. |
Side effects
- deletes related media rows only when
delete_mediais on (with an API key: only withdelete_media=1); 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
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 is
500minor units (GET /api/app-data→products.min_price_cents); below it the call answers422witherrors_code: price_below_minimum - prices above the compliance-review threshold (
products.manual_price_review_threshold_cents, currently15000= 150.00) put the product into manual review:needs_price_review_approval: trueandpurchase_block_code: price_review_pendinguntil Fangate approves - there is no upper limit, but PayPal is offered up to a
priceof 250.00 USD / 224.00 EUR and card payments up to 440.00 USD / 395.00 EUR / 200.00 GBP; above that buyers can only pay with crypto (see Payment limits above) - 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