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
| Rail | Trigger | Money flows | You are paid | What you configure |
|---|---|---|---|---|
| Digital goods | The viewer buys a goods entry you declared in your manifest | Viewer's Buzz → you | Immediately, on the purchase | Everything: which goods exist, their priceBuzz, whether they are good or app_unlock |
| Per-generation author fee | Your app runs a generation for a viewer | Viewer's Buzz → you | Daily, as one credit per currency | Nothing yet. Platform defaults apply to every app, including yours |
| Fiat Buzz rev share | The viewer buys Buzz with a card inside your block | Card payment → platform → you (a share of the net) | At payout, as a backpay over tracked rows | Nothing. The rate is a platform rate card |
Two things that look like earning rails and are not:
page.buzzBudgetPerGenis 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.
// 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:
Your share floors; the platform takes the remainder. The two parts always sum to
priceBuzzexactly — a database constraint enforces it, so rounding can never create Buzz — which means the rounding goes to the platform on any price wherepriceBuzz × 0.7is not a whole number. At a price of10you keep7and the split is exactly 70/30. At a price of11you keepfloor(7.7) = 7, the platform keeps4, and your effective share is 63.6%. If the exact ratio matters to you, price in multiples of 10.The floor price is 2, and it is derived, not chosen.
tsexport const BLOCK_GOOD_MIN_PRICE_BUZZ = Math.ceil(1 / BLOCK_GOOD_APP_OWNER_SHARE); // 2At a price of
1,floor(1 × 0.7)is0— you would sell an item and earn nothing from it, permanently and silently.2is the cheapest price at which your share is at least1.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
| Constant | Value | Where it bites |
|---|---|---|
BLOCK_GOOD_APP_OWNER_SHARE | 0.7 | Your share of every sale |
BLOCK_GOOD_MIN_PRICE_BUZZ | 2 | Cheapest listable price |
BLOCK_GOOD_MAX_PRICE_BUZZ | 50_000 | Ceiling 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_MANIFEST | 32 | Most 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
# 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:
| Constant | Value | Meaning |
|---|---|---|
BLOCK_AUTHOR_FEE_DEFAULT_FLAT_BUZZ | 1 | Flat leg: 1 ⚡ per generation |
BLOCK_AUTHOR_FEE_DEFAULT_PCT_OF_BASE | 0.05 | Percentage leg: 5% of the base generation cost |
BLOCK_AUTHOR_FEE_MAX_FLAT_BUZZ | 100 | Platform ceiling on the flat leg. No floor — 0 is legal |
BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE | 1 | Platform ceiling on the percentage leg (100% of base). No floor |
BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE | 10_000 | The 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)is0, every time. The platform's1 ⚡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 be1— 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
- 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.
- 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.buzzBudgetPerGensized 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:
- At submit, the viewer is debited the fee and one
accruedrow is written to theblock_author_fee_accrualledger naming you as the payee. - 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 surface | Publisher share of net |
|---|---|
publisher_all_my_models — your block installed across a publisher's models | 15% |
viewer_personal — a viewer's own install of your block | 25% |
per_model_install — legacy, no longer emitted for new attributions | 15% |
platform_default — a platform-default placement | 0% |
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 onmessage, 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, orunknown.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 answer500withcharge_failed, and both are retryable; several409s are not.
reason | Status | Charged? | Retryable | What your app should do |
|---|---|---|---|---|
price_over_cap | 400 | none | no | The approved price exceeds the platform ceiling. Ship a new version at a legal price |
price_changed | 409 | none | no | Your UI showed a stale price. Re-read the catalog, show the new price, get a fresh confirmation |
self_purchase | 400 | none | no | The buyer is the app owner. Never retryable — see the callout above |
already_owned | 409 | none | no | The viewer already holds a live entitlement. Re-read entitlements and render the owned state |
insufficient_funds | 400 | none | yes | Offer a top-up. A retry after the viewer buys Buzz genuinely can succeed |
duplicate | 409 | none | no | Another attempt already completed this purchase. Re-read entitlements; do not charge again |
pending_reconciliation | 409 | none | no | An earlier attempt is still being settled. Point the viewer at support — retrying walls them |
ledger_conflict | 409 | unknown | no | The ledger id is occupied, including by a reversal. Nothing the viewer can do; this one is for a human |
charge_failed | 400 / 500 | none or reversed | yes | Either nothing was charged or it was charged and given straight back. Safe to offer a retry |
charge_unknown | 503 | unknown | yes | We 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:
| Refusal | Status | reason | Note |
|---|---|---|---|
| The good is not available (no such id, or the approved manifest no longer declares it) | 404 | absent | This is also how a good removed or broken by a later approved version stops being sellable — there is no separate delisting step |
| Purchase rate limit | 429 | absent | Carries Retry-After. Transient: nothing moved, so a retry once the window clears is allowed |
| Viewer's daily purchase limit reached | 400 | absent | The message names the daily ceiling. Not something your pricing can fix |
| Purchase limiter unavailable | 503 | absent | Transient. Retry |
| An attempt with this idempotency key is already in progress | 409 | absent | Wait for the first attempt; do not mint a new key |
| The idempotency key was already used for a different purchase payload | 422 | absent | A key is pinned to a payload fingerprint. Use one key per logical purchase |
| The idempotency store is unavailable | 503 | absent | Transient. 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".
code | Status | Means | Your remedy |
|---|---|---|---|
insufficient_scope | 403 | The token never carried the scope this route needs | A manifest / approval problem. Declare the scope, justify it, resubmit. The name mirrors RFC 6750's OAuth 2.0 bearer error |
consent_revoked | 403 | The token does carry the scope and the viewer has since withdrawn it | Stop asking. Let the host re-prompt; retrying the call cannot help |
context_binding | 403 | The 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_revoked | 403 | This install went away, or the publisher was banned | Terminal for this instance. Not a scope or consent issue |
app_not_approved | 403 | The app block is not approved | Nothing runtime-side to fix; this is the review gate |
permission_state_unavailable | 503 | Permission 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
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>;
}Related
- Manifest reference — the
goodscatalog shape, its bounds, and sizingpage.buzzBudgetPerGen - Scopes reference —
goods:purchase:self,goods:read:self,ai:write:budgeted, and which scopes need a justification - Hooks reference —
useGoodPurchase,useEntitlements,useBuzzPurchase,useBuzzBalance - Moving a block off the bridge — the REST routes behind the goods hooks, and which credential reaches them
- Generating images and Comfy on Civitai — the generation paths the author fee rides
- Review, approval and deploy — why a price change is a new version