# stapel-billing 0.21.1

Payments and billing: per-user credit wallets held as expiry-aware credit lots over an immutable ledger, reservations (hold/capture/release) for work priced only after it runs, partial charges that record what a wallet could not cover as a collectable debt, plan bundles granted with or without a payment provider, one-off credit packages and subscription plans, Stripe-backed checkout, customer portal and refund clawback, idempotent webhooks and a service-to-service debit endpoint.

Contract: axes 1 · surface 69 · extension points 6 · operations 11 · error codes 56.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- PAYMENT_PROVIDER [enum, default "stapel_billing.providers.stripe.StripeProvider"] — Payment processor
  Which payment backend handles checkout, subscriptions and webhooks. Stripe out of the box; any other processor plugs in as a host-project provider class.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- apply_stored_event — stapel_billing.services.apply_stored_event
  instead of: stapel_billing.webhooks.get_stripe_handler
  Run ONE stored StripeWebhookEvent through the handler registry under the lock on its idempotency claim, the stale mark and the processed mark, in one atomic block. This is the delivery path minus HTTP: the webhook view calls it, and so must anything else that drives handlers. A second copy of the wrapping is how one path ends up without the lock.
- begin_event — stapel_billing.services.begin_event
  Clear the stale mark before a handler runs. The webhook view calls it; so must anything else that drives handlers (a replay, a backfill).
- can_afford — stapel_billing.services.can_afford
  instead of: stapel_billing.models.Wallet.balance, stapel_billing.services.hold
  Ask whether a charge of N credits would go through — a pure read over the live lots: no lock, no hold, no row written. Use it for pre-flight refusals and for showing or hiding an action. Never probe with a real hold you then release: each probe returns the credits as a NEW lot, fragmenting the wallet without bound.
- cancel_provider_subscription — stapel_billing.services.cancel_provider_subscription
  instead of: stripe.Subscription.modify
  Cancel the subscription on the processor's side, raising when the remote call fails — deliberately the FIRST half of a cancellation. Call it before marking anything cancelled locally: a local cancellation the processor never heard about tells the user billing stopped while their card keeps being charged.
- claim_provider_object — stapel_billing.services.claim_provider_object
  instead of: stapel_billing.models.Transaction.objects.filter(metadata__stripe_invoice_id=...).exists
  Claim one provider object (a checkout session, an invoice) for a grant, returning False when someone already claimed it — the database-enforced answer to "has this been paid out yet?". Call it inside the transaction that grants, so claim and credit commit together; a read-then-credit check over ledger metadata cannot see a concurrent delivery of a DIFFERENT event describing the same invoice, and both of them pay.
- claw_back_grant — stapel_billing.services.claw_back_grant
  instead of: stapel_billing.services.debit
  Take the credits of one grant back after a refund, dispute or credit note: consumes only that grant's lots, forgives what already expired unspent, records what was already spent as a debt the next top-up collects. The three refund webhooks call it; reach for it when money goes back some other way.
- consume_stale_event — stapel_billing.services.consume_stale_event
  Read and clear the stale mark after a handler ran — the value that becomes StripeWebhookEvent.ignored_stale.
- expire_credit_lots — stapel_billing.tasks.expire_credit_lots
  The task that makes credit expiry real: zeroes lots past expires_at, one EXPIRATION ledger row each. Wire it via get_billing_beat_schedule — nothing else enforces the deadline subscription credits are sold with (check stapel_billing.W105).
- expire_holds — stapel_billing.tasks.expire_holds
  Hourly crash-safety task: releases credit holds past expires_at, recorded as expired. Wire it via get_billing_beat_schedule; without it a pipeline that dies between hold and capture reserves a customer's credits for good.
- grant_plan_bundles — stapel_billing.tasks.grant_plan_bundles
  The worker that grants the current period's bundle to every entitled wallet — without it a free plan's monthly_credits_included is a number nothing reads. Idempotent per period; wire it via get_billing_beat_schedule().
- handle_charge_failed — stapel_billing.services.handle_charge_failed
  Built-in handler for `charge.failed`, the one-off-purchase half of the above. A bare charge names no subscription, so an unattributable failure is dropped rather than mailed at a guess.
- handle_charge_refunded — stapel_billing.services.handle_charge_refunded
  Built-in handler for `charge.refunded`: claws the granted credits back out of that grant's lots, prorated when the refund was partial. Name it only to wrap or replace it.
- handle_checkout_completed — stapel_billing.services.handle_checkout_completed
  The exact reconciliation this module performs when checkout.session.completed fires: award the package's credits, or open/refresh the Subscription and grant the plan's first monthly bundle, each with its payment.completed / subscription.changed announcement. Read it before writing a parallel handler for the same event, and never call it outside StripeWebhookView's StripeWebhookEvent claim + row lock — at-least-once delivery double-grants otherwise.
- handle_credit_note_created — stapel_billing.services.handle_credit_note_created
  Built-in handler for `credit_note.created`: an invoice credited back takes its bundle back too. Name it only to wrap or replace it.
- handle_dispute_created — stapel_billing.services.handle_dispute_created
  Built-in handler for `charge.dispute.created`: claws back when the dispute OPENS, not when it resolves — a dispute won later is one manual credit, a dispute lost after the credits are spent is unrecoverable.
- handle_invoice_paid — stapel_billing.services.handle_invoice_paid
  The renewal grant on invoice.paid — the month-two-onward credits a subscriber would otherwise never receive; skips the initial invoice (already granted at checkout) and claims the invoice through claim_provider_object, so the several distinct events Stripe sends for one paid invoice credit it exactly once. Read it instead of writing a product-side renewal grant, and call it only under the same webhook claim protocol as its siblings.
- handle_invoice_payment_failed — stapel_billing.services.handle_invoice_payment_failed
  Built-in handler for `invoice.payment_failed` — unregistered before 0.14.0, so a declined renewal produced no fact and no letter. Grants and claws back nothing; emits `payment.failed` so the subscriber can be told.
- handle_subscription_deleted — stapel_billing.services.handle_subscription_deleted
  The terminal cancellation on customer.subscription.deleted: marks the local Subscription cancelled with its timestamp and announces it. Read it before letting a product decide on its own what a deleted provider subscription means locally; call it only under the webhook claim protocol.
- handle_subscription_updated — stapel_billing.services.handle_subscription_updated
  The status reconciliation on customer.subscription.updated: maps the processor's lifecycle (active / trialing / past_due / canceled / incomplete) onto SubscriptionStatus, refreshes the billing period and announces the change. That mapping is the module's contract — anything deciding what "past_due" means locally must read it here rather than invent a second answer.
- is_internal — stapel_billing.internal.is_internal
  Is this user one of the deployment's own, per INTERNAL_ACCOUNT_RESOLVER. A resolver that raises answers False: 'we could not tell' falls on the side that charges.
- merge_idempotency_key — stapel_billing.services.merge_idempotency_key
  The deterministic ledger key merge_wallets writes for one pair of accounts. Recompute it to ask whether a merge has already been applied, or to find the row that explains a survivor's balance.
- meter_only — stapel_billing.internal.meter_only
  Should this user's spend be recorded at zero cost — the question the wallet services ask before charging. True only when the deployment opted into 'meter_only' AND the account is internal. Use it rather than reimplementing the two-part test.
- note_stale_event — stapel_billing.services.note_stale_event
  Say, from a webhook handler, that this payload was older than the state already applied and was deliberately not applied; the view records it as processed-and-ignored.
- policy — stapel_billing.internal.policy
  The deployment's internal-account policy: 'charge' (default) or 'meter_only'. Anything unrecognised reads as 'charge' — a typo must not turn the meter off.
- real_money — stapel_billing.services.real_money
  Narrow a Transaction queryset to rows where money actually arrived. Use it rather than writing the filter: `exclude(metadata__simulated=True)` also drops rows whose metadata lacks the key — every purchase before 0.18.0 — so the obvious query under-reports revenue silently.
- reconcile_subscriptions — stapel_billing.services.reconcile_subscriptions
  instead of: stapel_billing.models.Subscription.objects.update
  Re-read every provider-backed Subscription and repair status, billing period, cancel_at_period_end and cancelled_at from the provider. Reach for it after a release that changes how a provider payload is read, or a delivery gap: a webhook that was processed green but wrote the wrong value is never retried by anything. Idempotent; dry_run=True first. Never hand-fix rows in a shell — that writes one field and leaves the rest disagreeing with the provider.
- reconcile_wallet_balances — stapel_billing.services.reconcile_wallet_balances
  Report every wallet whose cached balance disagrees with the lots it summarises. Read-only on purpose: a balance rewritten to match destroys the evidence that something wrote it from outside this module.
- reconcile_wallets — stapel_billing.tasks.reconcile_wallets
  Daily task over reconcile_wallet_balances: logs at ERROR every wallet whose balance cache no longer matches its lots — how a deployment learns something wrote Wallet.balance directly.
- replay_webhook_events — stapel_billing.services.replay_webhook_events
  instead of: stapel_billing.models.StripeWebhookEvent.objects.update
  Re-run stored webhook events through the live handler path: the repair for a delivery that failed on a defect since fixed, once the provider's retry window closed and the stored row is the only copy left. Idempotent, and a payload older than the state it would overwrite is recorded ignored_stale, never applied. dry_run=True lists without running a handler. Never re-apply a payload by hand in a shell.
- staff_is_internal — stapel_billing.internal.staff_is_internal
  The default INTERNAL_ACCOUNT_RESOLVER: is_staff or is_superuser. Point the setting at your own callable when 'ours' means something else here.
- verify_stripe_signature — stapel_billing.services.verify_stripe_signature
  instead of: hmac.compare_digest, stripe.Webhook.construct_event
  The authenticity gate for an inbound provider callback: verifies the signature through the configured provider and returns the parsed event, raising on anything unsigned or on an unconfigured processor. Every endpoint that accepts webhooks must pass through it — a hand-rolled HMAC comparison is how an anonymous POST turns into a free credit grant.
- was_waived — stapel_billing.internal.was_waived
  Did this ledger row come out of the meter-only branch — the filter a usage or revenue report uses to include or exclude the deployment's own testing.
### predicate
- comp_plan_for_user — stapel_billing.services.comp_plan_for_user
  The plan an open comp window entitles a user to, or None. Call it if you resolve plans yourself, or comp time a support agent promised buys the customer nothing.
- effective_plan — stapel_billing.services.effective_plan
  instead of: stapel_billing.models.Subscription.plan
  The plan that governs a user right now: the higher-ranked of the provider's subscription and an open comp window (0.21.0). Subscription.plan only mirrors what is being paid for, so a product gating on it shows the paywall to somebody just given access.
- get_plan — stapel_billing.catalog.get_plan
  instead of: stapel_billing.models.Plan.choices, stapel_billing.models.Plan.values
  THE answer to "is this a plan?" — the configured catalogue entry for a slug, or None. Never the models.Plan enum: that is the ladder this library ships, while a host sells its own through STAPEL_BILLING['PLANS'], so a gate spelled `slug in Plan.values` refuses that deployment's real customers.
- governing_comp_period — stapel_billing.services.governing_comp_period
  instead of: stapel_billing.models.CompPeriod.objects.filter
  Of the comp windows open right now, the one entitling to the most (highest-ranked plan, longest end as tie-break). Windows overlap since upgrade comps start immediately, and picking by end date alone hands back the cheaper plan.
- latest_comp_period — stapel_billing.services.latest_comp_period
  instead of: stapel_billing.models.CompPeriod.objects.filter
  The comp window running longest into the future, or None: what a second grant would stack on. Read it before offering comp time, so 'a month on us' twice is a decision, not an accident.
- live_comp_period — stapel_billing.services.live_comp_period
  instead of: stapel_billing.models.CompPeriod.objects.filter
  The comp window open for a subscription right now, or None — why an account is entitled to a plan the provider is not paying for.
- plan_rank — stapel_billing.catalog.plan_rank
  Where a plan sits on the deployment's ladder — its POSITION in STAPEL_BILLING['PLANS'], lowest first — or None when unconfigured. The one ordering of plans: price and bundled credits each rank somebody's ladder upside down (an invoiced enterprise plan is priced 0).
- plan_slugs — stapel_billing.catalog.plan_slugs
  Every configured plan slug, in ladder order — for the message a refusal owes its operator: "unknown plan 'startr' — configured: free, starter, pro" is a typo somebody can fix.
- provider_event_time — stapel_billing.services.provider_event_time
  instead of: django.utils.timezone.now
  The provider's own clock for a webhook event (its `created`). Order lifecycle writes by this, never by arrival: Stripe does not promise delivery order.
- replayable_events — stapel_billing.services.replayable_events
  The event rows a replay would run, oldest first: named ids, or every row the delivery path never finished (what billing_invariants counts as webhook_unprocessed).
### factory
- capture — stapel_billing.services.capture
  instead of: stapel_billing.services.debit
  Close a hold at what the work actually cost, writing the one ledger row that explains it. Under-spend goes back to the lots it came from, expiry intact; over-spend takes the difference and can still refuse. Never debit after a hold — that charges twice.
- create_checkout_session — stapel_billing.services.create_checkout_session
  instead of: stripe.checkout.Session.create
  Open a paid checkout for a catalogue package or plan and get back (checkout_url, session_id), provider-agnostically. Use it rather than building a session at the processor: this is what stamps the user_id/package/plan metadata that the webhook handlers reconcile against later, and a session assembled by hand comes back from the provider carrying nothing to grant credits on.
- create_customer_portal — stapel_billing.services.create_customer_portal
  instead of: stripe.billing_portal.Session.create
  URL of the processor's self-service billing portal for a customer id — the sanctioned destination for "manage my card, invoices and cancellation". Reach for it instead of calling the provider's portal API directly, and instead of rebuilding those screens in a product.
- credit — stapel_billing.services.credit
  instead of: stapel_billing.models.Transaction.objects.create, stapel_billing.models.Wallet.balance
  Add credits to a wallet AND write the immutable ledger row that explains them — one transaction, under the wallet's row lock. This is the only sanctioned way credits come into existence: a product that creates its own Transaction, or bumps Wallet.balance, breaks the balance_after invariant that every audit, refund and GDPR export reads back.
- current_period_key — stapel_billing.services.current_period_key
  instead of: datetime.datetime.strftime
  The key naming the current bundle period ('2026-08'). It is grant_plan_bundle's idempotency unit, so spell it with this rather than formatting a date: two spellings of one month are two bundles.
- debit — stapel_billing.services.debit
  instead of: stapel_billing.models.Transaction.objects.create, stapel_billing.models.Wallet.balance
  Spend credits, refusing with InsufficientCreditsError rather than letting a balance go negative — the charging primitive behind every metered feature. Pass idempotency_key from anything delivered at-least-once (comm, webhooks, retried jobs) and a duplicate call returns the original transaction instead of charging twice; a hand-written ledger write has neither the lock nor that guarantee.
- default_plan_bundle_entitlements — stapel_billing.services.default_plan_bundle_entitlements
  instead of: stapel_billing.models.Subscription.objects.filter
  The stock answer to 'who gets a non-provider bundle': every wallet whose plan bundles credits Stripe is not already granting. Wrap it when writing your own resolver instead of re-deriving the Stripe-vs-local rule that stops a subscriber being granted twice.
- extend_subscription — stapel_billing.services.extend_subscription
  instead of: stapel_billing.models.Subscription.current_period_end
  Give a customer subscription time on the house: a CompPeriod row the entitlement surface honours once the provider's period stops entitling, carrying who/when/why. Dry run unless apply=True. Use it instead of editing Subscription.current_period_end, which mirrors Stripe and is overwritten by the next webhook.
- get_billing_beat_schedule — stapel_billing.tasks.get_billing_beat_schedule
  Spread `**get_billing_beat_schedule()` into CELERY_BEAT_SCHEDULE to wire all three billing workers at once (credit expiry, abandoned-hold release, reconciliation). Hand-authoring the entries is how expire_credit_lots gets silently dropped.
- get_credit_packages — stapel_billing.catalog.get_credit_packages
  instead of: django.conf.settings.STAPEL_BILLING["CREDIT_PACKAGES"], stapel_billing.catalog.DEFAULT_CREDIT_PACKAGES
  The resolved credit-package catalogue — host overrides applied, dicts coerced to CreditPackage. Read packages through this (or the lazy CREDIT_PACKAGES / CREDIT_PACKAGES_BY_SLUG views) instead of reaching into the settings dict: a deployment that sells its own packages has already replaced them here, while DEFAULT_CREDIT_PACKAGES is only what an unconfigured project falls back to.
- get_or_create_wallet — stapel_billing.services.get_or_create_wallet
  instead of: stapel_billing.models.Wallet.objects.get_or_create
  The one way to reach a user's Wallet — created on first touch, so no caller has to decide what "a user who never paid" means. Read balances through it instead of querying or creating Wallet rows yourself; a hand-made row is harmless, a hand-set balance is a ledger its transactions no longer explain.
- get_plans — stapel_billing.catalog.get_plans
  instead of: django.conf.settings.STAPEL_BILLING["PLANS"], stapel_billing.catalog.DEFAULT_PLANS
  The resolved subscription-plan catalogue, each entry carrying the entitlements map the billing.check_entitlement Function answers from. Read plans through this rather than from settings or DEFAULT_PLANS, or a product will price its pages and gate its features against a catalogue this deployment does not actually sell.
- get_provider — stapel_billing.services.get_provider
  instead of: stapel_billing.providers.stripe.StripeProvider, stripe.api_key
  Resolve the configured PaymentProvider (STAPEL_BILLING["PAYMENT_PROVIDER"], Stripe by default) — the entry point for a billing flow this module does not already serve. Reach for it instead of importing the Stripe SDK or instantiating StripeProvider: a deployment that swapped the processor, and a test that configured none, both have to arrive through here.
- grant_credits — stapel_billing.services.grant_credits
  instead of: stapel_billing.models.Wallet.balance, stapel_billing.services.credit
  Put credits on an account by hand — the one audited entry point behind `manage.py billing_grant_credits` and the admin's Grant credits action. Requires a reason and an actor, writes them onto an `adjustment` row, and is repeat-safe under an idempotency_key. Reach for it whenever credits must move without a payment: staff testing, goodwill, an invoiced agreement.
- grant_plan_bundle — stapel_billing.services.grant_plan_bundle
  instead of: stapel_billing.services.credit
  Grant one period's plan bundle WITHOUT a payment provider — the free tier, an invoiced agreement, a plan a host assigns itself. Idempotent per (wallet, plan, period), and the lot expires with the period. Every other grant here runs off a Stripe webhook, so a plan not sold through Stripe granted nothing until you call this.
- hold — stapel_billing.services.hold
  instead of: stapel_billing.services.debit
  Reserve credits BEFORE work whose real cost is only known afterwards. The credits leave the lots immediately, so two concurrent requests cannot both spend the last one; nothing is billed until capture. Use it instead of debiting an estimate — a refund that never runs because the worker died is a charge for work nobody did.
- live_lots — stapel_billing.services.live_lots
  instead of: stapel_billing.models.CreditLot.objects.filter
  The wallet's live credit lots in the exact order debit() will spend them — expiring soonest, non-expiring last — read without taking the row lock. Report expiry through it (a "3000 credits expire on the 28th" banner, an admin screen) instead of querying CreditLot: a product that orders the lots itself has forked the consumption rule, and will show a deadline the charge does not honour.
- mark — stapel_billing.internal.mark
  Stamp a metadata dict as a waived, metered operation and record what it would have cost. Only a host writing such a row itself needs to call it.
- merge_wallets — stapel_billing.services.merge_wallets
  instead of: stapel_billing.services.credit
  Fold one account's wallet into another's when auth reports a user.merged. Moves the lots — never a summed balance, because a lot carries its own expiry and adding two turns an expiring bundle into cash forever — then the transactions, holds and debts, and writes one ADJUSTMENT row so the survivor's new balance is explained rather than appearing. Exactly once under at-least-once redelivery.
- open_debts — stapel_billing.services.open_debts
  instead of: stapel_billing.models.CreditDebt.objects.filter
  One wallet's uncollected debts, oldest first — the order they are collected in. Read it to show an owner why their next top-up will partly disappear; the collection itself happens inside credit().
- open_holds — stapel_billing.services.open_holds
  instead of: stapel_billing.models.CreditHold.objects.filter
  The reservations still holding a wallet's credits (status=held) — the credits already gone from the spendable balance and not yet billed. Ask it what is outstanding rather than filtering CreditHold by hand: captured, released and expired holds are history, and counting them is how a product invents a second, wrong 'available balance'.
- outstanding_debt — stapel_billing.services.outstanding_debt
  instead of: stapel_billing.models.CreditDebt.objects.aggregate
  Total credits a wallet owes, as one integer — what stands between its balance and what the owner can spend next period. 0 for almost every wallet.
- plan_bundle_entitlements — stapel_billing.services.plan_bundle_entitlements
  instead of: django.conf.settings.STAPEL_BILLING["PLAN_BUNDLE_ENTITLEMENTS"]
  The configured entitlement resolver, normalised to {user_id, plan} rows and tolerant of one bad row. Call this, not the setting's callable, if you drive the grants from your own scheduler.
- plan_choices — stapel_billing.catalog.plan_choices
  instead of: stapel_billing.models.Plan.choices
  Model-field `choices` over the configured catalogue, as a callable, so a form validates against the deployment's plans. Use it on any host column storing a plan slug; an enum-backed list makes the admin refuse to save the row it is displaying.
- release — stapel_billing.services.release
  instead of: stapel_billing.services.credit
  Give a hold's credits back when the work failed. Each portion returns with the expiry of the lot it came from, so a released subscription bundle does not become credits that never expire. Idempotent through the hold; skipping it locks the credits until expire_holds notices.
- resolve_account — stapel_billing.services.resolve_account
  Turn what an operator typed — a primary key or an e-mail — into the account they meant, raising rather than returning None when nothing or more than one thing matches. Use it in any manual path that names an account, so a grant cannot land on nobody or on the wrong one of two rows.
- simulate_checkout_completed — stapel_billing.services.simulate_checkout_completed
  instead of: stapel_billing.services.credit
  A staff member buys a credit package without a card, by simulating the RETURN from the processor — so reconciliation, the one-grant-per-session claim, the ledger row, the lot and `payment.completed` all run as a real purchase runs them. Gated on WHO by the caller, never on a setting. Every row and the emitted fact carry `simulated`.

## Extension points — what a product replaces, fork-free
- AUTH_USER_MODEL [swappable_model]
  Standard Django user swap — wallet/subscription rows bind to settings.AUTH_USER_MODEL; the module never references a concrete user class.
- CREDIT_PACKAGES [catalog_override]
  Replace the one-off credit package catalog from settings (list of dicts or CreditPackage instances) — re-read lazily on every access, no fork.
- PAYMENT_PROVIDER [dotted_path]
  Swap the payment backend: implement the PaymentProvider ABC (checkout, portal, cancel, webhook verify) in the host project and point the setting at it.
- PLANS [catalog_override]
  Replace the subscription plan catalog from settings (list of dicts or PlanCatalogEntry instances) — re-read lazily on every access, no fork.
- PLAN_BUNDLE_ENTITLEMENTS [dotted_path]
  Decide who receives a plan bundle that no payment provider grants (the free tier, an invoiced agreement, a plan the host assigns itself). Point the setting at a callable yielding {user_id, plan} rows; the default reads the local Subscription row, which is only right for hosts that keep plan membership there.
- serializer_seams [class_override]
  Every billing view declares request/response serializer seams (SerializerSeamMixin) — subclass the view, override the attribute, remount the URL.

## Fits with — fleet dependencies
- stapel-auth (optional) — issues the authenticated user sessions the wallet/checkout/subscription endpoints require and the user.deleted event this module consumes; the model binding itself is the standard AUTH_USER_MODEL swap
- stapel-core (required) — comm bus (payment.completed / subscription.changed emits, user.deleted consumer), JWT authentication, GDPR provider registry, AppSettings config layer

## HTTP operations (11) — call by operationId, never by a typed path
Paths are relative to `/billing/api/v1/`.
### Catalog
- GET /products — billing_api_v1_products_retrieve
### Checkout
- POST /checkout — billing_api_v1_checkout_create
- POST /internal/debit — billing_api_v1_internal_debit_create
- GET /portal — billing_api_v1_portal_retrieve
### Internal
- POST /checkout/simulate — billing_api_v1_checkout_simulate_create
### Subscription
- POST /subscription/cancel — billing_api_v1_subscription_cancel_create
- GET /subscription — billing_api_v1_subscription_retrieve
### Wallet
- PATCH /wallet — billing_api_v1_wallet_partial_update
- GET /wallet — billing_api_v1_wallet_retrieve
- GET /wallet/transactions — billing_api_v1_wallet_transactions_retrieve
### Webhooks
- POST /webhooks/stripe — billing_api_v1_webhooks_stripe_create

## Error codes (56) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.amount_invalid [400] fix_input
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.invalid_package [400] fix_input
- error.400.invalid_plan [400] fix_input
- error.400.invalid_stripe_signature [400] contact_support
- error.400.invalid_webhook_payload [400] contact_support
- error.400.redirect_url_not_allowed [400] contact_support
- error.400.redirect_url_not_configured [400] contact_support
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.insufficient_credits [402] fix_input
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.forbidden_billing [403] contact_support
- error.403.network_blocked [403] contact_support
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.not_found [404] retry
- error.404.subscription_not_found [404] fix_input
- error.404.transaction_not_found [404] fix_input
- error.404.verification_challenge_not_found [404] verify
- error.404.wallet_not_found [404] fix_input
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.409.duplicate_webhook_event [409] retry
- error.409.subscription_not_paid [409] verify
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.mandate_unavailable [503] retry
