Cloud billing

Cloud usage draws on a prepaid balance. A team buys credits with Add funds, and can turn on automatic refill so a saved card tops the balance up before it runs out. Cloud work never runs on credit: when the balance cannot pay for the next slice of a running job, the job stops.

Cloud size labels select a fixed allocation and its exact credit class: sparkwing-2cpu, sparkwing-4cpu, sparkwing-8cpu, sparkwing-16cpu, sparkwing-32cpu and sparkwing-64cpu. Each label bills its corresponding core class; an unpriced class cannot be awarded. Without a size label, Cloud uses sparkwing-2cpu. The sparkwing- prefix is reserved for Cloud. Requiring a Cloud size label selects a fresh, isolated Cloud pod. Self-hosted, personal and pool runners use other labels for their own sizes and cannot advertise Cloud labels. A GitHub App push requiring a Cloud size needs credits at intake. An unfunded push records a terminal run that did not start. Adding credits later does not start that delivery's run. Use Run now after funding.

A store can switch once from test to live payment mode using --payment-mode at controller startup, after draining test work. The first live start persists the switch; restarting live preserves it. Switching from live to test is refused, including after team deletion. Live balances, standing, limits, refills and payment decisions use only live records. Test rows remain in the store for history; they never fund live work. The switch forgets each team's test Stripe customer and disables automatic refills; add a live card and opt in again to use live refills.

Test-to-live cutover runbookSection anchor link

  1. Stop new test work and let awarded work finish or cancel it. Let test refills reach a final provider result before stopping the test controller.
  2. Stop the test controller and start with --payment-mode live and live Stripe credentials. Startup refuses unsettled test awards, reservations and open refills with "drain metered work before switching to live; see the cutover runbook". If refused, restart in test mode and finish draining.
  3. Add live funds, save a live card, and enable live refills if needed.

The switch commits before requests or background workers start. It restarts storage accounting at the cutover instant, so time spent in test mode cannot become a live debit. Later live starts preserve the accounting watermarks.

Operator free grants require an explicit idempotency key. A mistaken free grant can be corrected with a negative reversal naming its reference. Corrections cannot take back more than that grant, and cannot reverse paid credits.

StandingSection anchor link

Standing caps how much Cloud capacity a team can use at once and how long one job may run. Payment history alone caps its automatic refills; see Automatic refill.

PolicyConcurrent Cloud vCPUMaximum duration per jobHistory requirementPrepaid balance backing this standing
New84 hoursSaved card, no payment yet$5
Established324 hoursFirst payment$20
Trusted1286 hoursAt least $50 paid and 30 clean days$100
High5126 hoursAt least $500 paid and 30 clean days, or operator grant of standing. More capacity is available by request.None; prepaid funds never back High
Held0 new admissionsRunning Cloud work receives a durable cancellationDispute, early fraud warning, operator hold or revoked standingAn active hold takes precedence over prepaid funds
Launch admissionAutomatic admission stops at 100 admitted accounts. Further signups join the waitlist until an operator admits them. An account named in --operator-accounts is admitted at its next sign-in.

Standing is the higher of the history tier and the tier backed by the current prepaid balance. Prepaid funds raise standing at most one tier above the history tier and never above Trusted. A first payment earns Established history, so prepaying alone reaches Trusted at most. High needs $500 paid and 30 clean days, or an operator grant of standing, whatever the balance. Add funds and automatic refills both count as payments.

A hold restarts the clean days when it is placed and again when it is released. An operator hold that the operator who placed it releases as "no fault found" counts as if it never happened. Dispute, fraud-warning and other automatic holds always restart the count.

Adding funds raises standing in the settlement transaction. Spending the balance can lower its balance-backed tier; payment history stays.

Admissions include resources already awarded and reserved for jobs that have not started. A job larger than its standing's capacity is refused immediately with the standing named. Each Cloud award carries a hard deadline. Renewal cannot extend it. Reclaiming a job keeps its first Cloud admission clock. A later standing change does not shorten a running job's awarded deadline. The launcher passes over teams without standing capacity so other teams can receive work. Local and self-hosted placement does not consume Cloud standing capacity or receive a standing deadline.

A team owner can contact us for High standing, higher limits or larger payments from the billing page or with sparkwing billing request-tier --profile cloud --tier high --message <text>, with a message of up to 1,000 characters (Unicode code points). A team has at most one open request. The request reaches the operator console's standing queue with the team's standing, payment history and message. The operator approves it by granting standing or declines it with a reason. Each decision names the request it reviewed and is refused once that request is no longer open, so a stale or retried decision never resolves a later request. Both decisions are audited, and the billing page and sparkwing billing standing show the outcome. High-standing teams can also send requests. Describe the payment size or limits needed in the message so the operator can contact you. Granting High standing does not raise the maximum per payment.

New accounts receive no automatic free credits. Operators retain the per-account credit grant action.

Add fundsSection anchor link

Team owners buy credits from the console billing page. Add funds opens a Stripe Checkout page, and the credits land when the controller receives Stripe's signed webhook for the paid checkout. The billing page also saves a card through a Stripe setup page; a saved card is what automatic refill charges.

Stripe holds every card detail. Sparkwing stores no card brand, last four digits, fingerprint, payment method ID or payer email. Per team it keeps only the Stripe customer ID and when a card setup last completed, plus the checkout, payment and refund IDs, amounts, modes and statuses its credit ledger needs. Once a team has added a card, the billing page's Manage card button (POST /api/v1/team/billing/portal) opens Stripe's customer portal, where owners view, replace or remove it. Automatic refill charges the customer's default payment method in Stripe. When a card is set up, the controller passes the team owner's email to Stripe for its receipts without storing it.

Each Add funds payment and automatic refill is at least $10, so card fees stay a small share of it, and at most $1,000, at every standing tier including High. Other amounts are refused before a payment starts. Use the billing page contact request, or sparkwing billing request-tier --profile cloud --tier high --message <text>, to ask about larger payments. The request reaches the operator console queue.

Every Add funds checkout asks Stripe to request 3-D Secure. Stripe can still complete a payment without authentication, for example when the card or issuer does not support it or authentication fails to run. The controller records the result Stripe reports on the settled charge, or none, with the paid checkout and its audit event; it does not refuse a payment by its result.

Checkout and saved-card setup requests require an idempotency key. Retry an interrupted request with the same key. The controller retains its reservation after a provider failure and returns the saved page after completion. Changing the request under that key conflicts. An expired page requires a new key.

Each team can reserve 20 Stripe Checkout Sessions in a rolling hour, shared by Add funds and saved-card setup. Retries with the same idempotency key count once. Failed provider requests retain their reservation and count toward the limit because the provider may already have created the session. The controller refuses the next new session with rate_limited, current usage and a retry delay. Contact support if your team needs more. The billing portal and automatic refills do not create Checkout Sessions and do not use this allowance.

Automatic refillSection anchor link

Automatic refill is off until an owner turns it on, and turning it on needs a saved card. The owner sets a threshold and a refill amount: when the balance falls below the threshold, the controller charges the saved card the refill amount, between $10 and $1,000. The owner can also set a weekly and a monthly limit on refills; zero means no limit.

Set refills on the console billing page, with sparkwing billing refill (--enable or --disable, --threshold <dollars>, --amount <dollars>, --week-limit <dollars>, --month-limit <dollars>), or with PUT /api/v1/team/billing/refill. Settings take effect when their update commits.

Refills are capped by the team's paid history alone, so a stolen card cannot buy much compute before its owner disputes the charge. The prepaid balance never raises these caps. Refills use the history tier's payment and clean-day requirements listed above. An operator grant of standing gives the High caps. Teams that outgrow these caps use the contact request for higher limits.

Refill historyLargest refillRefills per dayRefill total per week
New$501$100
Established$2002$500
Trusted$1,0003$3,000

High still has the $1,000 maximum per payment and uses the team's own weekly and monthly limits.

The controller checks for due refills every 30 seconds and keeps at most one refill in flight per team. It opens a refill only after checking, in the same transaction, that refills are on, no failed refill blocks them, the team has no hold, the balance is below the threshold and the refill fits every limit. The refill fixes its amount and Stripe customer when it opens. The controller then makes one call that creates and confirms the off-session payment, with the refill's id as Stripe's idempotency key and in the payment's metadata. Until the payment settles, every pass rechecks the same eligibility, setting the refill's own reservation aside, and repeats that same request under the same key, so a crash or a lost answer never charges twice. A call the checkout service does not answer, or answers with a 5xx or 429, is repeated on the next pass, then after waits that double from 30 seconds up to 30 minutes; one it still cannot answer 6 hours after the first failure closes as checkout_unavailable. A refusal about how the two services are paired is retried the same way, because it is an operator slip rather than a fact about the team: a 401 (the billing-token credential and the service's BILLING_CHECKOUT_TOKEN differ), a 404 (--billing-url does not name the service) or a 421 (the service's BILLING_DEPLOYMENT is another deployment). The first one logs alert=checkout_misconfigured naming the likely cause, once until a call is answered again, and fixing the pairing within 6 hours affects no team. Any other refusal is about the request itself, such as an amount outside the bounds, another team's Stripe customer (403) or a request Stripe refused (422), and closes the refill as checkout_refused without another call. Either failure logs alert=checkout_failed for the operator and stops refills the way a decline does, and the owners get one email saying automatic refills stopped. A refill the team is no longer eligible for, for example because refills were turned off or the team's Stripe customer changed, is withdrawn without another call; a payment an earlier call made is still credited. A refill that nothing settles within 23 hours is closed as expired and the next one starts fresh; a payment that settles later is still credited. Credits settle only from Stripe's signed webhook, which names the refill in its metadata, through the same path as Add funds: the same fraud-warning handling, refund ordering and standing update apply.

Every open refill and every refill payment counts against the limits, whether granted, declined, refused or refunded. A refill the checkout service answered as charged keeps counting after it is withdrawn or expires. A refill that closes without a payment, such as one with no card or one that expired uncharged, does not. Runbook: a charge whose answer was lost is known only from its webhook, so if its refill is withdrawn or expires first, the next refill can pass a limit by that one payment; refund the extra payment in Stripe. A refill that would exceed a team limit or a cap is not made. The credits run down and Cloud work stops at zero; the billing page names the limit that stopped the refill (refill.limit in GET /api/v1/team/billing).

A refill that needs the cardholder to authenticate closes as failed with authentication_required, whether the checkout service's answer or a signed webhook reports it first. A refill the card declines, or that asks for authentication, is recorded with its decline code, and the team's owners receive an email asking them to update the card with Manage card. A refill that cannot be created because the Stripe customer has no card, for example after the card was removed in Stripe's portal, closes as no_card and notifies the owners the same way. A refill that an early fraud warning named before it settled is refused, refunded in full and holds the team. Either way no further refill starts until an Add funds payment succeeds or a new card setup completes; changing the card in Stripe's portal or changing the refill settings does not restart them. The balance runs down as usual. A delayed failure from a refill opened before a successful Add funds payment does not block refills again. A failure from a newer refill does.

MeteringSection anchor link

Cloud work is metered in 60-second slices. Admission reserves the first slice from the balance; a job whose first slice the balance cannot cover is refused with "out of credits", naming the balance and what the slice needs. Each lease renewal charges the time used, at least the job minimum, and reserves one slice past the time used in the same transaction, so the slice admission reserved also pays the minimum the first renewal charges. A metered lease never runs past its funded horizon. When the balance cannot pay the next slice, the renewal records the stop and cancels the metered job in the same transaction. A hold records its drain deadline with the financial obligation. The next renewal applies that deadline and cancels the metered job. The reason names the shortfall or hold and what resolves it. The owner and keeper stop execution when they receive that cancellation. The lease ends at the earlier of its paid horizon and a drain deadline at most one slice away. The host kills the SDK process tree at that bound, including commands that ignore cancellation, and asks DinD to kill its containers within the same stop budget. SDK cancellation and cleanup share the time remaining under the bound. The launcher tears down the pod at the funded bound while it is reachable. During a launcher outage, Kubernetes Jobs enforce the last funded duration plus at most one extra 60-second slice, with zero termination grace. Their clock starts at Kubernetes StartTime, so the pod can also outlive the funded bound by its activation-to-start delay, which has no fixed upper bound. This extra runtime is never charged to the customer. Only an acknowledged renewal extends the funded deadline. Renewals cannot move a funding stop's deadline. Lifting the hold or adding funds does not resume the stopped job. Final settlement refunds the unused part of the last slice. A later Cloud award can start if the remaining balance covers its first slice and the team has no hold. A funding refusal alone does not hold the team.

When a funding stop or hold stops a metered job, only that job stops. The run's owner keeps orchestrating, and independent unmetered jobs continue. Normal dependency settlement decides which descendants can run. When it stops the run's owner itself (a trigger or plan running on Cloud), the whole run stops, including jobs on customer-owned runners, because its orchestrator has stopped.

Retained storage is charged only from the balance there is: once the balance reaches zero, storage accounting advances without a debit. The unfunded part is forgiven and is not collected after a top-up.

HoldsSection anchor link

A hold stops new Cloud admissions and records cancellation and a drain deadline for active metered work in the same transaction. Running Cloud work stops when the owner or keeper receives the cancellation, or when its funded lease expires if the controller connection is lost. During a launcher outage, Kubernetes allows at most one extra 60-second slice plus the delay between Job activation and Kubernetes StartTime beyond the last funded deadline. That start delay has no fixed upper bound. The customer is never charged for this extra runtime. Releasing the hold before the next heartbeat still leaves that cancellation in place. Start a new run after the hold is resolved. Unmetered jobs continue when their run's owner remains active. A hold has one of four causes:

  • an operator hold, placed from the operator console;
  • an open payment dispute;
  • an early fraud warning on one of the team's payments;
  • revoked standing.

Each refusal and notice names the hold and points at support. A lost dispute keeps its hold until an operator releases it. A won dispute, or a closed warning dispute, releases its hold.

Refunds, disputes and stolen cardsSection anchor link

Any card payment can be refunded or disputed after its credits are spent. The clawback takes the credits back even when that leaves the balance negative, and a negative balance blocks new Cloud work until the team adds funds.

The controller's --payment-mode configures both provider verification and the store's ledger mode. Provider events must match that mode. Empty mode disables settlement. Stripe refund objects omit livemode, so refunds use the signed event envelope's mode. refund.created, refund.updated and refund.failed settle each refund by its ID. charge.refunded is acknowledged without a ledger effect because its charge total repeats those individual refunds.

Each controller store holds a random payment deployment id, created once at first start and never changed by configuration, so renaming the controller's host leaves its in-flight payments its own. The checkout service stamps that id as metadata[sparkwing_deployment] on every Checkout Session, setup intent, payment intent and customer it creates for the controller. Deployments that share one Stripe account each receive the other's events at /webhooks/stripe. After checking the signature, and before any Stripe read or store write, the controller classifies each event once:

  • An object stamped with another deployment is foreign: acknowledged with 204, never read from Stripe or settled. If this controller recorded that object, the mismatch is an integrity fault instead: it is logged at error level with alert=payment_ownership_conflict, answered with a 500 so Stripe retries, and never settled.
  • An object stamped with this deployment, or one that resolves to a checkout, page, intent or refill this controller recorded, is local and settles. An unstamped refund, dispute or fraud warning whose payment is not recorded yet is local only when its payment intent carries this deployment's stamp. A refund, dispute or warning is then kept against its payment until its payer becomes known.
  • An unstamped event that resolves to nothing is acknowledged. Card setup pages and Add funds payment intents settle through another object, so their other events are acknowledged too.

Card setup and Add funds pages are correlated by their Checkout Session, which exists before payment. A completed card setup binds its verified setup intent to the page's team. If setup completion arrives first, the controller keeps its verified facts and saves the card when the page binds that intent. A refill's payment intent carries purpose=card_refill and settles on its own events. Unsigned and badly signed requests are refused.

Stripe Radar handles card fraud. Sparkwing does not recognize a card across teams, so abuse of one card across many teams is stopped by a Radar rule or by the operator by hand.

An actionable early fraud warning on a payment this controller settled holds the team whose payment it names, and no other. Sparkwing treats a settled 3-D Secure result of authenticated as protected. The warning leaves that payment's credits frozen under its hold and queues no refund. attempt_acknowledged, missing results and all other results are unprotected. For those payments, the warning takes back the credit even when that leaves the balance negative and queues a full refund unless Stripe already returned it. The controller stores the authentication result in three_d_secure and its protection decision separately in liability_shift when the payment settles. This decision follows Sparkwing's warning policy. Stripe's authentication result does not prove liability protection in every case. Neither stored value changes on replay or gets read from Stripe again when a warning arrives. A warning that arrives before its payment settles refuses an unprotected payment, which grants nothing and is refunded. A refused payment raises the card_payment_refused alert, and each submitted refund raises card_refund. Refund submissions with succeeded or pending status log at info. Failed or canceled submissions retain warn logs, and checkout service failures log at error.

Refunds, lost disputes and fraud warnings are kept against their payment whatever order Stripe delivers them in. A pending payment retains each reversal obligation. Settlement applies those obligations in the transaction that grants the credit, using the stored protection decision for warnings. A refused payment has no granted credit to reverse. All reversals together take back at most the original grant. Only actual refunds count toward a queued refund already being paid. A replay of a signed event is recognized by what Stripe signed, not by the 3-D Secure result read from Stripe afterwards. All events and refund responses for one payment serialize before reading its payer or outcomes. A refund response binds only the provider's refund ID to the queued refund. Verified callbacks supply its amount and outcome, including when an earlier partial refund leaves less than the original payment to return. The worker refunds under the payment as its idempotency key, so a call that fails before Stripe answers repeats the same refund, with the same backoff as a refill. A refund Stripe reports failed or canceled is not retried, and neither is one the checkout service refuses or still cannot answer 6 hours after the first failure, which also logs alert=checkout_failed: the operator console lists it on the team under Refunds Stripe did not make, and the operator returns the money in Stripe. Runbook: a refund marked failed this way may still have been made, if Stripe refunded before an answer was lost, so check the payment's refunds in Stripe before refunding by hand; a second refund of the same payment fails with charge_already_refunded and moves no money. The team's owners get one email saying the refund of that amount for the payment on its date could not be completed and that Sparkwing will follow up. A terminal refund outcome survives a later pending delivery, and a conflicting terminal outcome is refused.

Team deletion never waits for money to finish moving. Purge removes operational data, refill settings and the team's Stripe customer ID, retaining the team id as a tombstone. Sparkwing holds no card details to scrub. Checkouts, payments, refunds, grants, charges, holds and their payer association survive. Late settlements, refund callbacks and fraud warnings continue to reconcile after purge under the same exclusive team lock used by other financial mutations.