Skip to content

How an app earns ​

An App Block can earn on three separate rails. They are separate ledgers with separate triggers, separate settlement cadences, and — for two of them — opposite directions of money flow. Nothing about one tells you anything about another.

Selling a good and selling Buzz are OPPOSITE directions

This is the single easiest thing to get wrong on this page, and the SDK's own useGoodPurchase docblock says so too.

  • A digital good is Buzz flowing from the viewer to you. The viewer already holds the Buzz; you take a share of it.
  • The Buzz rev share is fiat flowing into the viewer's balance, and you take a share of the card payment. The viewer ends the transaction with more Buzz than they started with.

They share nothing but the fact that one can unblock the other: a viewer who cannot afford your good can be sent through a top-up, and the top-up is itself attributable.

The three rails at a glance ​

RailTriggerMoney flowsYou are paidWhat you configure
Digital goodsThe viewer buys a goods entry you declared in your manifestViewer's Buzz → youImmediately, on the purchaseEverything: which goods exist, their priceBuzz, whether they are good or app_unlock
Per-generation author feeYour app runs a generation for a viewerViewer's Buzz → youDaily, as one credit per currencyNothing yet. Platform defaults apply to every app, including yours
Fiat Buzz rev shareThe viewer buys Buzz with a card inside your blockCard payment → platform → you (a share of the net)At payout, as a backpay over tracked rowsNothing. The rate is a platform rate card

Two things that look like earning rails and are not:

  • page.buzzBudgetPerGen is a spend ceiling, not income — the most Buzz a single generation your block submits may cost the viewer. Sizing it is covered in the manifest reference. It interacts with the author fee (see below) but pays you nothing.
  • Tips (social:tip:self) move Buzz to a creator, not to your app.

Rail 1 — digital goods ​

You declare a catalog in your manifest; the platform sells those entitlements to viewers for Buzz on your behalf and records the ledger. The field-by-field shape, the review gating, the entitlement-key warning and the scopes you need are in the manifest reference and the scopes reference — this section is only about the money.

The split ​

The app owner keeps 70%; the platform keeps the remainder.

ts
// civitai:src/shared/constants/block-goods.constants.ts
export const BLOCK_GOOD_APP_OWNER_SHARE = 0.7;

// The single source of truth for how a sale splits — the payout and anything
// that DISPLAYS the numbers both read it, so what is shown equals what is paid.
const appOwnerShare = Math.floor(priceBuzz * BLOCK_GOOD_APP_OWNER_SHARE);
const platformShare = priceBuzz - appOwnerShare;

Three consequences of that expression, in order of how likely they are to surprise you:

  1. Your share floors; the platform takes the remainder. The two parts always sum to priceBuzz exactly — a database constraint enforces it, so rounding can never create Buzz — which means the rounding goes to the platform on any price where priceBuzz × 0.7 is not a whole number. At a price of 10 you keep 7 and the split is exactly 70/30. At a price of 11 you keep floor(7.7) = 7, the platform keeps 4, and your effective share is 63.6%. If the exact ratio matters to you, price in multiples of 10.

  2. The floor price is 2, and it is derived, not chosen.

    ts
    export const BLOCK_GOOD_MIN_PRICE_BUZZ = Math.ceil(1 / BLOCK_GOOD_APP_OWNER_SHARE); // 2

    At a price of 1, floor(1 × 0.7) is 0 — you would sell an item and earn nothing from it, permanently and silently. 2 is the cheapest price at which your share is at least 1.

  3. It is paid immediately. There is no batching and no settlement delay on this rail: the purchase either completes with your share credited, or it is refused and nothing moves.

You are credited in the same currency proportions the viewer paid in. A purchase spends the viewer's granted (blue) Buzz before the Buzz they bought (yellow), and your share is prorated back across the same pools, flooring so the blue leg can never exceed the proportional amount.

The bounds you are priced inside ​

ConstantValueWhere it bites
BLOCK_GOOD_APP_OWNER_SHARE0.7Your share of every sale
BLOCK_GOOD_MIN_PRICE_BUZZ2Cheapest listable price
BLOCK_GOOD_MAX_PRICE_BUZZ50_000Ceiling on a single good, re-checked at purchase time as well as at manifest validation — so an old approved manifest cannot keep charging a price the ceiling has since moved below
BLOCK_GOOD_MAX_PER_MANIFEST32Most goods one manifest may declare

Above your per-good ceiling sits a per-viewer daily ceiling across every app. A purchase at a perfectly legal price can still be refused because the viewer has spent their day's allowance somewhere else, and that refusal is not something your app can price its way out of.

The share is a policy coincidence, not a constant alias

BLOCK_GOOD_APP_OWNER_SHARE is deliberately not an alias of the cosmetic shop's creator share, even though the two are the same number today. They are independent knobs that currently agree, so do not reason from one to the other.


Rail 2 — the per-generation author fee ​

Every generation your app runs for a viewer can carry an author fee: an additive, viewer-paid amount that goes to you. The platform takes no cut of it and funds none of it — it is a conduit. You are credited exactly what the viewer was debited.

This rail is live and on by default. You do not opt in, and today you cannot tune it: per-app configuration is a later change, and until it lands the platform defaults apply to every app, including apps that already existed. The platform retains a switch that stops new fees being quoted, reserved or charged; it does not stop a fee already accrued from being refunded.

The formula ​

text
# civitai:src/server/services/blocks/author-fee.ts
fee = max(flatBuzz, pctOfBase × base_generation_buzz)

The max is deliberate — it is "largest of flat or percent", not a sum, and not a percentage with a floor expressed some other way. Whichever leg wins is recorded as the governing leg (flat, pct, or none when the fee is zero); at the crossover, where the legs are equal, flat wins as a pinned tie-break.

The platform defaults:

ConstantValueMeaning
BLOCK_AUTHOR_FEE_DEFAULT_FLAT_BUZZ1Flat leg: 1 ⚡ per generation
BLOCK_AUTHOR_FEE_DEFAULT_PCT_OF_BASE0.05Percentage leg: 5% of the base generation cost
BLOCK_AUTHOR_FEE_MAX_FLAT_BUZZ100Platform ceiling on the flat leg. No floor — 0 is legal
BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE1Platform ceiling on the percentage leg (100% of base). No floor
BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE10_000The percentage leg is evaluated in whole basis points, as exact integer arithmetic

The platform table also carries a per-generation-type override for chat-completion, and it is a zero — flatBuzz: 0, pctOfBase: 0 — so a conversational block earns nothing per turn. That is intentional: chat completion is the highest-frequency generation type an app runs, and a flat floor on each turn would be a per-message toll rather than a fee on a generation.

Which base, and every rounding direction ​

  • The percentage is taken of the BASE cost, not the total. The total a viewer pays for a generation already carries other creators' model licensing fees, a lineage fee and any tips. Charging a percentage of that would be charging a percentage of someone else's fee, and would compound as more fee-charging resources stack onto one generation. Your percentage leg sees the base only.
  • Every rounding goes toward the viewer. The stated fraction floors to whole basis points, and the resulting Buzz floors again — so a stated 5% never charges more than 5%. That direction is what makes the guarantee exact rather than approximate, and it is also why a low percentage with a zero flat leg would earn nothing on cheap generations: floor(4 × 500 / 10000) is 0, every time. The platform's 1 ⚡ flat default exists to avoid exactly that.
  • A zero-cost generation earns nothing, and that is a separate rule from the formula. A plain max(1, 5% × 0) would be 1 — the flat leg minting a fee out of a generation that cost nothing. The zero-base guard returns before the legs are evaluated, and zero-base generations do happen.
  • A provisional price earns nothing today. When the orchestrator reports the price as a cap that may settle lower — at least one step post-billed and charged up front at its maximum, with the difference refunded — the fee is skipped rather than charged against money the viewer may not ultimately spend. This is recorded as the current answer, not a settled policy.

Two things this rail does to your app's own numbers ​

  1. The fee is folded into the cost your block is quoted, with no itemised field. A cost estimate your block asks for comes back as a total that already includes the author fee. If you display that number, you are displaying generation cost plus fee, and there is no separate line item to subtract.
  2. The fee counts against the per-generation budget ceiling. The submit path compares generation cost plus fee against the per-call budget, so a page.buzzBudgetPerGen sized against a bare generation estimate can start refusing with an insufficient-budget reply once the fee is added. Leave headroom.

When you actually get paid ​

The rail is two hops:

  1. At submit, the viewer is debited the fee and one accrued row is written to the block_author_fee_accrual ledger naming you as the payee.
  2. Daily, those rows are summed per (owner × Buzz type × accrual day) and the total is minted to you as a single credit. You get one credit per currency per day, not one per generation. The credit's external id is shaped block-author-fee-<YYYY-MM-DD>-<userId>-<buzzType>, which is what to quote if you ever need to reconcile a day with support.

The batching exists for ledger volume, not for rounding — the fee is already floored to whole Buzz before the viewer is shown or charged it, so there is no sub-Buzz residue being carried.

A generation that does not succeed does not earn. When a generation reaches a terminal state that is not success, an accrual that has not yet settled is removed and the viewer is refunded. A row that has already settled is never reversed.

You cannot earn this fee from yourself. If the viewer running the generation is the app owner, the fee is refused before any money moves — the same self-dealing exclusion the goods rail applies, resolved from one shared predicate.


Rail 3 — the fiat Buzz rev share ​

When a viewer buys Buzz with a card, inside your block, the platform records a block_buzz_attribution row against your app and you are owed a share of the payment. This is the rail that runs in the opposite direction to goods: the viewer's balance goes up.

The basis is the net — gross minus the payment provider's fee — and the share depends on the surface the purchase happened on:

Attribution surfacePublisher share of net
publisher_all_my_models — your block installed across a publisher's models15%
viewer_personal — a viewer's own install of your block25%
per_model_install — legacy, no longer emitted for new attributions15%
platform_default — a platform-default placement0%
viewer_global — a full-page app (app.page, no model entity)0%

Read that table before you assume this rail is worth building for: a page app currently attributes at 0%. The zero is a deliberate placeholder — page revenue is treated as largely platform-counterfactual — and raising it needs a new rate card, not a code change on your side.

Two mechanics worth knowing:

  • Rate cards are immutable. A row stamps its rate-card version at write time and pays out under that snapshot for the life of the row. Changing a percentage means a new card; it never retroactively reprices rows already written.
  • Membership purchases are tracked, not rated at write time. A block-initiated membership payment is recorded per paid invoice — the initial purchase and each renewal — with the author share computed at payout as a backpay over the tracked rows rather than stamped on them. The rate the active card carries today is 15% of net, mirroring the purchase floor as a conservative starting default pending monetization sign-off. Recurring revenue is structurally different from a one-shot purchase, so treat that figure as the rate a backpay would apply rather than a committed number.

Both of those mean the number you will eventually be paid on this rail is settled at payout, against the card the row was written under. Neither the write nor the row itself is a price quote.

Wired, but barely exercised

This rail is built end-to-end across the host, the payment webhooks and the ledger — and almost nobody has earned on it, plausibly because it has never been documented anywhere until now. If you intend to build a business on it rather than on goods or the author fee, talk to the Civitai team first instead of inferring behaviour from this page.


Refusal reasons your app must branch on ​

Every money path refuses in ways your app has to tell apart, and the machine- readable discriminators are not all on the same key.

You cannot buy your own app's items

An app owner purchasing their own good is refused: 400, reason: "self_purchase", nothing charged, and retryable: false. Retrying will never work.

The reason is arithmetic, not policy theatre: an owner buying their own good would pay themselves 70% through the bank and burn the other 30% — a self-discount with a platform fee attached, not a sale.

This bites hardest when you are testing your own catalog. Test a purchase as a viewer who is not the app owner, or the only thing you will ever measure is this refusal.

Goods purchases — the reason key ​

A refused purchase answers with { ok: false, error, reason }. In the @civitai/blocks-react binding this surfaces as a rejected promise carrying a GoodPurchaseRefusal with status, message and reason.

Three fields decide what your app should do, and they are independent of each other:

  • reason — why. Branch on this, never on message, which is viewer-facing copy and will be reworded.
  • charge (on the server result) — what this attempt did to the viewer's Buzz: none, reversed, or unknown.
  • retryable — whether an identical retry could reach a different verdict. 🔴 It is not derivable from the status class. Both a pre-charge database failure and a post-charge reversal answer 500 with charge_failed, and both are retryable; several 409s are not.
reasonStatusCharged?RetryableWhat your app should do
price_over_cap400nonenoThe approved price exceeds the platform ceiling. Ship a new version at a legal price
price_changed409nonenoYour UI showed a stale price. Re-read the catalog, show the new price, get a fresh confirmation
self_purchase400nonenoThe buyer is the app owner. Never retryable — see the callout above
already_owned409nonenoThe viewer already holds a live entitlement. Re-read entitlements and render the owned state
insufficient_funds400noneyesOffer a top-up. A retry after the viewer buys Buzz genuinely can succeed
duplicate409nonenoAnother attempt already completed this purchase. Re-read entitlements; do not charge again
pending_reconciliation409nonenoAn earlier attempt is still being settled. Point the viewer at support — retrying walls them
ledger_conflict409unknownnoThe ledger id is occupied, including by a reversal. Nothing the viewer can do; this one is for a human
charge_failed400 / 500none or reversedyesEither nothing was charged or it was charged and given straight back. Safe to offer a retry
charge_unknown503unknownyesWe cannot say whether Buzz moved. Retry with the same idempotency key; do not present it as a clean failure

🔴 reason is frequently absent, and a switch with no default will swallow real failures. Only the service-level refusals above carry one. The endpoint's own refusals answer with { error } and no reason at all:

RefusalStatusreasonNote
The good is not available (no such id, or the approved manifest no longer declares it)404absentThis is also how a good removed or broken by a later approved version stops being sellable — there is no separate delisting step
Purchase rate limit429absentCarries Retry-After. Transient: nothing moved, so a retry once the window clears is allowed
Viewer's daily purchase limit reached400absentThe message names the daily ceiling. Not something your pricing can fix
Purchase limiter unavailable503absentTransient. Retry
An attempt with this idempotency key is already in progress409absentWait for the first attempt; do not mint a new key
The idempotency key was already used for a different purchase payload422absentA key is pinned to a payload fingerprint. Use one key per logical purchase
The idempotency store is unavailable503absentTransient. Retry

Always fall back to status plus message when reason is missing.

One code you will find in the source and should not branch on

The server's refusal union declares a good_not_found member, and no code path returns it — the unavailable-good case is the bare 404 in the table above, with no reason. Do not write a branch for it.

Scope and permission refusals — the code key ​

Every block REST route runs behind the scope middleware, which refuses with a different key: code, alongside the human error string. These are not money-specific — they apply to any block route, including the goods endpoints — and telling them apart is the whole reason code exists. Every one of these means something different, and the remedy differs every time — do not collapse them into "permission denied".

codeStatusMeansYour remedy
insufficient_scope403The token never carried the scope this route needsA manifest / approval problem. Declare the scope, justify it, resubmit. The name mirrors RFC 6750's OAuth 2.0 bearer error
consent_revoked403The token does carry the scope and the viewer has since withdrawn itStop asking. Let the host re-prompt; retrying the call cannot help
context_binding403The token carries the scope but the request does not match what it was bound to (wrong model id, an anonymous subject on a self-bound scope, an array-form query param)Re-request a token for the right context. Not a consent problem
instance_revoked403This install went away, or the publisher was bannedTerminal for this instance. Not a scope or consent issue
app_not_approved403The app block is not approvedNothing runtime-side to fix; this is the review gate
permission_state_unavailable503Permission state could not be read right now🔴 The only retryable one, and the only one that is not a 403. Retry shortly. Treating it as a permanent denial takes away access the viewer still has

Two refusals on this surface carry no code: an invalid or expired block token answers 401, and an unavailable approval lookup on a route that does not elect to be served answers 503. Branch on status for those.

Putting it together ​

tsx
import { useEntitlements, useGoodPurchase, GoodPurchaseRefusal } from '@civitai/blocks-react';

function BuyButton({ goodId, priceBuzz }: { goodId: string; priceBuzz: number }) {
  const { purchase, loading } = useGoodPurchase();
  const { refetch } = useEntitlements();

  async function onBuy() {
    try {
      await purchase(
        { goodId, expectedPriceBuzz: priceBuzz },
        { idempotencyKey: `buy:${goodId}:${attemptId}`, topUpOnInsufficientFunds: true },
      );
      refetch();
      return;
    } catch (err) {
      // A refusal the server produced deliberately, as opposed to a transport failure.
      if (err instanceof GoodPurchaseRefusal) {
        switch (err.reason) {
          case 'already_owned':
          case 'duplicate':
            refetch();                       // they have it — render the owned state
            return;
          case 'price_changed':
            showStalePriceNotice(err.message); // re-confirm at the new price
            return;
          case 'self_purchase':
            showOwnerNotice();               // never retryable
            return;
          case 'charge_unknown':
            // Buzz may have moved. Retry with the SAME idempotencyKey; never
            // present this as a clean failure.
            showUncertainNotice(err.message);
            return;
          default:
            // 🔴 REQUIRED. `reason` is undefined for the 404, the 429, the
            // daily-cap 400 and every idempotency refusal.
            showRefusal(err.status, err.message);
            return;
        }
      }
      // Not a refusal: a 30s timeout (the charge may have landed — retry with the
      // same key) or an AbortError because the component unmounted.
      if (err instanceof Error && err.name === 'AbortError') return;
      showTimeoutNotice();
    }
  }

  return <button disabled={loading} onClick={onBuy}>Buy for {priceBuzz} Buzz</button>;
}

Civitai Developer Documentation