Skip to content

Webhooks ​

Outbound webhooks are creator-registered HTTPS endpoints where Fangate pushes payment, product, file and account events to third-party integrators. This page describes them.

Fangate also receives server-to-server callbacks from its own payment and verification providers. Those run between Fangate and the provider, are authenticated per provider and are not part of the public API - there is nothing to call or configure there.


Outbound webhooks (integrators) ​

Creators and integrators register HTTPS endpoints to receive real-time payment, product and file notifications instead of polling the transaction and media APIs.

  • Auth: Creator Sanctum bearer token or API key with api-key:webhooks.manage (see API Keys).
  • Delivery: Async queue (webhooks queue); does not block payment processing.
  • Retries: Up to 7 delivery attempts. A failed attempt is retried after 1 min → 5 min → 30 min → 2 h → 6 h → 24 h, each measured from the previous attempt; the last one runs about 32.5 hours after the first, then the delivery is marked failed. media.* events stop earlier: 5 attempts, 1 min → 5 min → 30 min → 2 h, done about 2.5 hours after the first - a file verdict is far more frequent than a sale, and a receiver that was away longer asks for a redelivery.
  • HTTPS only at registration time.
  • One account per webhook. A webhook registered with a creator's key (or login) hears that account only - and, since 2026-10-12, the creator's own AI models. Its deliveries carry no account identifier, except that a delivery about one of the creator's AI models adds an account block (account_key aim_<id>, kind: ai_model, name), as agency-wide webhooks do. A product that connects many creators registers one webhook per creator and tells them apart by the URL it chose (for example a path segment of its own creator id) or by the signing secret. account.verified carries account_id, the user.id of the account.

Supported events (v1) ​

EventTrigger
payment.successfulPurchase completed and confirmed by PSP (payment_status = success)
payment.failedPayment rejected, declined, or cancelled (failed / cancelled)
payment.pendingPayment initiated but not yet confirmed (e.g. Epoch, PayPal)
payment.cancelledA sale that had been paid was taken back - refund, chargeback or a cancellation by Fangate. Sent once, with the transaction_id of the payment.successful it reverses, the reason and the fee.
product.sellableA product can be bought for the first time: moderation, consent and (above the threshold) price approval are through. Sent once per product.
product.blockedA product cannot be sold because its content was rejected, a required consent is not approved or the seller took it off sale. Carries a reason. Sent once per product and reason.
refund_request.rejectedA refund you requested for a sale (refund requests) is not granted. The payment payload of the sale - it stays paid - plus request_reason (your text) and rejection_reason. A granted request arrives as payment.cancelled.
media.approvedOne file passed moderation - by the automated check or by the Fangate team. data: media_id, product_id (null for a vault file), client_reference, product_ids, status: "approved". See File verdicts.
media.rejectedOne file was rejected. Same fields plus reason (automatically_rejected_manual_review or manually_rejected).
account.verifiedThe account passed its identity verification and may upload. Sent once, when the account turns verified - whether Veriff approved it, the decision poll fetched the approval or Fangate verified it by hand. data: account_id, is_verified: true, decision: null.
account.verification_declinedVeriff did not approve a session of an account that is not verified yet: decision is declined, resubmission_requested (the creator can submit again in the same session) or underage (approved by Veriff, but under 18 - Fangate refuses). data: account_id (the user.id of the account), is_verified: false, decision. After declined or underage a program may open a new session for the creator with GET /api/veriff/create. Sessions that expire or are abandoned are not reported; the same call answers 409 verification_pending (free, no session opened) until a new session may be opened.
agency.relationship_endedThe relationship between an agency and a creator ended - by the agency, the creator, Fangate, or because the agency account was deleted. Sent to the agency's and the creator's own webhooks. data: agency_link_id, agency_account_id, creator_account_id, creator_account_key (the conn_ key the agency used for this creator), ended_at, ended_by (agency, creator, admin, system).
agency.relationship_acceptedA creator accepted the agency's invitation and the relationship is active. Sent to the agency's and the creator's own webhooks. data: agency_link_id, agency_account_id, creator_account_id, creator_account_key (the conn_ key to send as X-Fangate-Account for this creator from now on), creator_name, accepted_at. Until this event an integration learned of a new creator only by listing GET /api/agencies/me/accounts again.
agency.company_reviewedFangate decided the agency's company review (the documents filed with POST /api/agencies/me/documents). Sent to the agency's own webhooks. data: account_id (the agency's user.id), is_verified (the representative's identity check), company_review with approved (boolean), kyb_status (approved or rejected) and reason (the team's note on a rejection, null on approval). Payouts are released once the company is approved and the representative is verified (account.verified).

A webhook only receives the events it lists, so a receiver registered for payment events never sees a new event type.

Reversals. Subscribe to payment.cancelled to learn that a paid sale is gone. For historical reasons the status change behind a reversal also sends payment.failed - once per reversal - so a receiver that lists both events gets both for the same transaction_id. payment.failed alone cannot tell a reversal from a payment that never went through; payment.cancelled is only sent for a sale that had been credited.

product.sellable timing: a new product is checked 3 seconds after creation, then every 5 seconds in the first minute, every 10 seconds in the second, every 20 seconds up to five minutes and once a minute up to 30 minutes; after that a sweep runs every 10 minutes. A product made from already approved vault files is reported within seconds; a fresh upload as soon as its moderation has finished.

product.blocked is the other outcome of the same checks. It is sent when the product cannot be sold for one of these reasons:

reasonMeaningWhat happens next
automatically_rejected_manual_reviewThe automated content check rejected every file of the product.The Fangate team reviews every automated rejection by hand. If the review approves the content, product.sellable follows; if it confirms the rejection, a second product.blocked with manually_rejected follows.
manually_rejectedThe Fangate team rejected the content by hand - for example after the manual review of a large video.Final for these files. Replace the content or contact support.
missing_consentA consent is required and not approved: nobody is tagged yet, the request is still open, or it was declined or rescinded.Tag the co-creator or wait for the answer; product.sellable follows once the consent is approved. A consent requirement can also be set automatically: the content check counts the people in a file and asks for consent when it sees more than one. That count can be wrong - an object, for example a toy, is sometimes taken for a second person - so the Fangate team reviews these requests by hand and lifts the ones set by mistake.
sales_stoppedThe seller took the product off sale - with sales_stopped: true or because sales_end_at has passed (see PATCH /api/products).Sent once per stop, for an end date within five minutes. Buyers who already bought keep their access.

File verdicts (media.*) ​

media.approved and media.rejected report the moderation verdict of one file, the moment its status becomes final - whether the automated check decided or the Fangate team did. They exist for what the product events cannot say: a vault file that belongs to no product yet, a file added to a product that is already live (product.sellable was sent once and does not repeat), and a rejected file next to approved ones (the product stays sellable, so product.blocked is silent). For a product uploaded as a single file the product events already say everything; subscribe to media.* when you track files.

json
{
  "event": "media.rejected",
  "timestamp": "2026-10-09T14:02:11Z",
  "data": {
    "media_id": "m_4711",
    "product_id": 42,
    "client_reference": "order-2026-000123",
    "product_ids": [42, 57],
    "status": "rejected",
    "reason": "automatically_rejected_manual_review"
  }
}
FieldNotes
media_idThe file (m_{id}), the same id media_ids carries on the product events.
product_idThe product the file was uploaded for; null for a vault file without a product.
client_referenceYour reference on that product, null when none.
product_idsEvery product the file hangs in - a vault file reused in several products lists them all; [] for a vault file without a product.
statusapproved or rejected.
reasonmedia.rejected only. automatically_rejected_manual_review: the automated check rejected the file; the Fangate team reviews every automated rejection by hand - if it approves, media.approved follows; if it confirms, a second media.rejected with manually_rejected follows. manually_rejected: rejected by the Fangate team, final for this file.

Verdicts, not sellability. A media.approved does not mean the product can be sold - other files may still be checked, a consent or a price approval may be open. Sellability is what product.sellable and product.blocked report, and GET /api/products/{product} shows the current purchase_block_code. Three things to know: a file that lands in the manual review queue (large videos, uploads of accounts under review) is reported only when the Fangate team approves it, and product.sellable can arrive earlier when another file of the product was approved; a file that is already approved and later attached to a new product sends nothing - there is no new verdict; a re-check (for example after a change of the file's origin) resets the file silently and another media.approved may follow.

purchase_block_code carries the finer state, the same value GET /api/products/{product} shows (consent_recipient_missing, consent_pending, consent_declined, consent_rescinded, moderation_not_approved, sales_stopped, removed_by_fangate - the last one with reason: manually_rejected). Each reason is sent once per product - sales_stopped once per stop; when a stopped product is put back on sale, a block that still applies is reported again. Waiting is not reported: while a file is still being checked, while a price approval is open or while a file waits in the manual review queue, neither event is sent - the outcome is. A rejected file next to an approved one does not block the product, so it sends nothing. The event also comes later than the creation window: whenever a file of a product is rejected - even after the product was live - and nothing sellable is left, product.blocked is sent.

What buyers keep. A block only stops new purchases; buyers who already bought keep their access. Access is judged file by file: a file that is rejected or removed, or whose consent is missing – declined, rescinded, not yet answered, or required after the sale – can no longer be viewed or downloaded, in every product that contains it. The other files of the purchase stay available, and the file returns as soon as consent is given. A copy the buyer already downloaded stays with the buyer.

The event describes that moment and is not repeated: media_ids lists the files attached when it was sent - read GET /api/products/{product} for the full list if you attach files afterwards. A file that needs consent and is attached later blocks the product again; when you build a product over several calls, mark it on the creating call as described under Products.

Registration API ​

MethodPathDescription
POST/api/webhooksRegister endpoint URL, event subscriptions, optional include_set_price
GET/api/webhooksList registered webhooks for the authenticated creator
PATCH/api/webhooks/{id}Update URL, events, include_set_price, or is_active
DELETE/api/webhooks/{id}Remove a webhook
POST/api/webhooks/{id}/redeliveriesHave a delivery of this webhook sent again

POST /api/webhooks ​

Request body:

json
{
  "url": "https://partner.example.com/hooks/fangate",
  "events": ["payment.successful", "payment.failed", "payment.pending"],
  "include_set_price": true
}
FieldRequiredNotes
urlYesMust be https://
eventsYesNon-empty array of supported event names
include_set_priceNoDefault false. When true, set_price is included in payment payloads (sensitive pricing data).
agency_scope_ai_modelsAgency accountsThis webhook hears the agency's AI models and model accounts. See Agency-wide webhooks
agency_scope_creatorsAgency accountsThis webhook hears the connected creators. A generated key needs access to connected creators for it
account_keysAgency accountsOptional: hold the webhook to these accounts. See Agency-wide webhooks

Response 201 includes the webhook resource plus secret (shown once). Store it immediately; it cannot be retrieved later via API.

An account holds up to 15 webhooks, all of them delivering in parallel; the next one answers 422 webhook_limit_reached. An agency usually needs one per client: hold it to that client's accounts with account_keys.

GET /api/webhooks ​

Returns an array of webhook objects without the signing secret.

Each object carries created_by_agency: null for webhooks you registered yourself, otherwise { id, name, link_id, link_active } for a webhook an agency registered while operating your account (agency accounts, since 2026-09). When that agency relationship ends, the webhook is switched to is_active: false and stops receiving deliveries; it stays listed so you can see what was configured, and you may delete it or re-enable it. Your own webhooks are not affected.

PATCH /api/webhooks/{id} ​

Partial update. Supported fields: url, events, include_set_price, is_active, and on an agency account agency_scope_ai_models, agency_scope_creators, account_keys.

DELETE /api/webhooks/{id} ​

Removes the webhook and its delivery history.

POST /api/webhooks/{id}/secret ​

Replaces the signing secret. Response 200 is the webhook resource plus the new secret, shown once. The old secret stops working at once - there is no period in which both are valid. The webhook, its events and its delivery history stay.

From that moment every delivery attempt is signed with the new secret, retries of earlier deliveries included. Store the new secret on your side right away; a delivery your endpoint rejects because it still checks the old secret is not lost - Fangate retries it on the normal schedule (1 min, 5 min, 30 min, ...; media.* up to 2 h). Limited to 30 calls per minute.

POST /api/webhooks/{id}/redeliveries ​

Sends a delivery of this webhook again - for a webhook you lost, or one your side could not process. Name it by exactly one of:

json
{ "transaction_id": "txn_123" }
json
{ "delivery_id": "550e8400-e29b-41d4-a716-446655440000" }
FieldNotes
transaction_idThe sale's id as GET /api/wallet lists it (transaction_id). Re-sends every delivery this webhook had for that sale.
delivery_idThe X-Fangate-Delivery-Id of a delivery you received.

The delivery goes out with its original body and the same X-Fangate-Delivery-Id, so a receiver that already processed it recognises the duplicate; delivered and failed deliveries can both be sent again, and a redelivery runs the normal retry schedule of its event (media.*: the short one). Response 202 lists what was queued (data.redelivered[] with delivery_id and event).

Statuserrors_codeWhen
404not_foundNot your webhook, or nothing was ever queued for this webhook and reference - the webhook did not exist, was inactive or did not list the event at the time. Read the sale from the wallet list instead.
409conflictThe delivery is still being retried (the message names the next attempt), or the webhook is inactive.
422validation_failedNeither or both of the two fields (a field sent as null counts as not given).
429rate_limited10 per minute and 300 per day per account; one redelivery per minute per delivery.

Delivery payload ​

Each POST to your URL uses Content-Type: application/json.

json
{
  "event": "payment.successful",
  "timestamp": "2026-05-19T10:42:00Z",
  "data": {
    "transaction_id": "txn_123",
    "buyer_email": "buyer@example.com",
    "seller_earning": 8.50,
    "seller_earning_minor": 850,
    "gross": 11.50,
    "gross_minor": 1150,
    "fees": 3.00,
    "fees_minor": 300,
    "buyer_telegram_id": 7412345678,
    "currency": "EUR",
    "product_id": 42,
    "client_reference": "order-2026-000123",
    "link_reference": "chatter-anna",
    "media_ids": ["m_101", "m_102"],
    "set_price": 10.00,
    "set_price_minor": 1000
  }
}

payment.cancelled is the payment payload of the reversed sale plus two fields:

json
{
  "event": "payment.cancelled",
  "timestamp": "2026-09-18T10:42:00Z",
  "data": {
    "transaction_id": "txn_123",
    "buyer_email": "buyer@example.com",
    "seller_earning": 8.50,
    "seller_earning_minor": 850,
    "gross": 11.50,
    "gross_minor": 1150,
    "fees": 3.00,
    "fees_minor": 300,
    "buyer_telegram_id": 7412345678,
    "currency": "EUR",
    "product_id": 42,
    "client_reference": "order-2026-000123",
    "link_reference": null,
    "media_ids": ["m_101", "m_102"],
    "reason": "chargeback",
    "cancellation_fee_minor": 1500
  }
}
FieldNotes
data.seller_earning / _minorThe amount that had been credited for this sale and is now reversed.
data.reasonrefund, chargeback or other.
data.cancellation_fee_minorThe fee this reversal costs your account, in minor units; 0 when none applies. The exact wallet rows (reversal and fee) follow a moment later in GET /api/wallet.

product.sellable has no buyer and no amounts:

json
{
  "event": "product.sellable",
  "timestamp": "2026-09-18T10:42:00Z",
  "data": {
    "product_id": 42,
    "client_reference": "order-2026-000123",
    "media_ids": ["m_101", "m_102"]
  }
}

product.blocked adds the reason to the same body:

json
{
  "event": "product.blocked",
  "timestamp": "2026-09-18T10:42:03Z",
  "data": {
    "product_id": 42,
    "client_reference": "order-2026-000123",
    "media_ids": ["m_101"],
    "reason": "missing_consent",
    "purchase_block_code": "consent_pending"
  }
}

The table below describes the payment events (payment.*, refund_request.rejected). product.* events carry product_id, client_reference and media_ids (plus reason / purchase_block_code on product.blocked); media.* events carry media_id, product_id, client_reference, product_ids, status and reason (see File verdicts); account.* events carry account_id, is_verified and decision - see Supported events.

FieldAlways includedNotes
eventYesEvent type string
timestampYesISO 8601 UTC - the event time. Retries resend the identical, signed body, so a retried delivery carries the original timestamp.
data.transaction_idYesFangate transaction id (txn_{id})
data.buyer_emailYesBuyer email on the transaction
data.seller_earningYesNet amount credited to the creator in major units (8.50 = 8.50 EUR). This differs from the REST resources, where every amount is in minor units (850) - do not divide by 100 here.
data.seller_earning_minorYes (payment events)The same amount in minor units (850), the convention of the REST API. Prefer this field; seller_earning stays for existing receivers.
data.gross / data.gross_minorYes (payment events)What the buyer paid for the sale, in major and minor units.
data.fees / data.fees_minorYes (payment events)Every fee of the sale in one sum - buyer fee, seller fee and the age check where it applied. gross = fees + seller_earning.
data.buyer_telegram_idYes (payment events)The buyer's Telegram user id when the purchase ran through the Telegram Mini App, otherwise null.
data.currencyYesISO 4217 currency code (uppercase)
data.product_idYesPurchased product id
data.client_referenceYesThe reference you set on the product (client_reference on POST / PATCH /api/products), null when none. On every event type - match a payment to your own order without keeping a product_id table.
data.link_referenceYes (payment events)The reference the fan's link carried - ?ref= on the sales link, -- on the Telegram link, # on the card query, see Your own reference per link - unchanged, null when it carried none.
data.media_idsYes (payment and product events)Always an array of media ids (m_{id}), even for a single file; media.* events carry the one file as media_id instead
data.set_priceNoOnly when include_set_price: true on the webhook; major units as well
data.set_price_minorNoSame condition; the price in minor units (1000)

The payment fields (transaction_id, buyer_email, the amounts, currency) are absent on product.sellable and product.blocked. A delivery that was queued before the minor-unit fields existed is retried with its original body, so treat them as optional for a few days after the release.

media_ids is always an array, matching the live n:m product_media pivot between products and library media (see Products and Media Library).

Agency-wide webhooks ​

An agency account holds no content itself - a webhook registered there listens to the accounts the agency runs. At least one of agency_scope_ai_models / agency_scope_creators is required there (422 otherwise); on every other account the two fields are stored as false. Sent together with X-Fangate-Account a scope is refused (422): inside an account a webhook belongs to that one account.

Held to single accounts (account_keys). Send account_key values from GET /api/agencies/me/accounts and the webhook hears those accounts only - one endpoint per client, for example. The two scopes then follow from the list (an AI model or a model account on it sets agency_scope_ai_models, a creator agency_scope_creators). account_keys: null on PATCH returns to every account of the scopes. The list is not shortened when a relationship ends: a refund of a sale made while the account was on the list still arrives. A PATCH replaces the list with the accounts you name and keeps what cannot be named any more - ended relationships and invitations to an address that has not registered yet. The webhook resource shows account_keys (open relationships only; null without a list).

Keys. A generated key hears connected creators only if it has access to them (can_access_connected_creators): agency_scope_creators or a creator in account_keys answers 422 otherwise. A key held to a list of accounts (API keys) registers agency webhooks only with an account_keys list inside its own. It lists, changes, removes, redelivers and rotates the webhooks it registered itself, and a webhook of someone else only when every account on that webhook's list - ended ones included - is on the key's list; a webhook without a list is closed to it (403 api_key_scope_requires_account). A key sends again only deliveries of accounts it may reach; others answer 404 like a delivery that does not exist. Rotating the secret of a webhook that hears creators needs access to connected creators. Revoking an agency key - or a key the agency created on a creator's account - switches off the webhooks it registered - they stay listed for the account, which can switch them back on or remove them. The same happens when such a key expires (within the hour after its expiry date; a webhook changed after the expiry is left alone). A creator's own key takes nothing with it.

Every delivery of such a webhook carries the usual payload plus a top-level account block:

json
{
  "event": "payment.successful",
  "timestamp": "2026-09-20T10:42:00+00:00",
  "account": { "account_key": "conn_418_90211", "kind": "creator", "name": "Mia Example" },
  "data": { "transaction_id": "txn_123", "buyer_email": "buyer@example.com", "...": "..." }
}

account_key is the value the X-Fangate-Account header takes; kind is ai_model, model_showroom or creator. The deliveries of an account's own webhooks are unchanged and carry no account block.

  • A sale event follows the relationship that was in force when the sale was made: payment.cancelled for an earlier sale still reaches the agency after the relationship ended or the AI model was deactivated. Product events follow the relationship active now.
  • A creator who switched off API access for the agency is left out from that moment on; webhooks the agency registered on that creator's account pause as well, and their deliveries cannot be sent again (403 agency_api_access_revoked) until access is back on.
  • Inside a creator's account an agency changes and removes only the webhooks it registered there itself (403 otherwise); it can list the others. The signing secret is never part of a list.
  • A blocked agency receives nothing - no new deliveries, no retries that come due, no redelivery on request.

Delivery headers ​

HeaderDescription
X-Fangate-SignatureHMAC-SHA256 of the raw JSON body using your webhook secret, format sha256={hex}; the hex digest is lowercase
X-Fangate-Signature-Timestampedt={unix seconds},v1={hex}: HMAC-SHA256 of the string {t}.{raw body} with the same secret, lowercase hex. t is the moment of this attempt, fresh on every retry - the scheme known from Stripe. Additive: X-Fangate-Signature above is unchanged.
X-Fangate-Delivery-IdUUID of the delivery. The same value on every retry attempt and on a redelivery - use it as the idempotency key
Content-Typeapplication/json

Verifying signatures (example) ​

The secret is 40 characters, letters A-Z, a-z and digits only. Use it exactly as returned: when you store it in an .env file or a secret manager, make sure no space, quote or line break is added - a trailing newline is the most common reason every signature fails. Compute the HMAC over the raw request body as received, byte for byte; a body that was parsed and serialized again (re-ordered keys, changed spacing or escaping) no longer matches.

php
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_FANGATE_SIGNATURE'] ?? '';
$secret = getenv('FANGATE_WEBHOOK_SECRET');

$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

The timestamped signature, with a five-minute tolerance:

php
$header = $_SERVER['HTTP_X_FANGATE_SIGNATURE_TIMESTAMPED'] ?? '';

if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)
    || abs(time() - (int) $m[1]) > 300
    || !hash_equals(hash_hmac('sha256', $m[1] . '.' . $payload, $secret), $m[2])) {
    http_response_code(401);
    exit('Invalid or stale signature');
}

Respond with 2xx to acknowledge successful processing. Non-2xx responses trigger retries per the backoff schedule.

Idempotency and replay protection ​

Three checks, in this order, make a receiver safe against duplicates and replayed captures:

  1. Verify the signature over the raw body (above). Anything that fails is dropped.
  2. Deduplicate on X-Fangate-Delivery-Id. The id is fixed per delivery and repeated on every retry, so a store of seen ids (kept for at least two days) rejects both Fangate's own retries after you already answered 2xx and a replayed capture. A redelivery you ask for weeks later carries the same id but may have left that store - so book a sale on data.transaction_id, which never changes, and a late duplicate cannot be booked twice.
  3. Reject stale deliveries. Easiest with X-Fangate-Signature-Timestamped: its t is the time of the attempt itself, so a five-minute tolerance works - for retries and redeliveries too. If you verify X-Fangate-Signature only, the one timestamp you have is timestamp in the body: it is the event time and stays the same while Fangate retries for about 32.5 hours (1 min, 5 min, 30 min, 2 h, 6 h and 24 h after the previous attempt; media.* stops after 2 h), so that window has to be at least 36 hours (plus clock skew) - and a redelivery you asked for later will be older than that.

Both signatures use the same secret; X-Fangate-Signature is unchanged, so existing receivers keep working. Report a replayed delivery to Fangate with its delivery id.

Out of scope (v1) ​

  • Delivery log API for integrators
  • More than 7 delivery attempts (5 for media.*)
  • Filtering events by product_id or media_id
  • An event for a consent block that arises after product.sellable was sent (a consent file attached later, a consent rescinded): read purchase_block_code on the product. A rejection after that point is reported.
  • Two valid secrets at the same time: POST /api/webhooks/{id}/secret replaces the secret at once - on a payment signal a leaked secret is replaced, not kept valid in parallel

Fangate backend developer documentation