Skip to content

Plan Change Preview

Use plan-change preview before a subscribed customer switches from one subscription product to another. Athena computes the credit, timing, and Google replacement-mode consequences from server state, then your app renders the result before opening the store purchase sheet.

TL;DR

  1. Customer selects a target package in your plan picker.
  2. Call POST /my/billing/plan-change-preview with only to_product_id.
  3. Render the returned message_key, credit fields, timing, and rate limit state.
  4. If plan_change_retry_after_seconds is not null, do not open the store purchase sheet.
  5. For Google, pass the returned google_proration_mode into the purchase call. Do not infer the replacement mode locally.
  6. For Apple, ignore google_proration_mode; it is always null.

When to Call It

Call preview when all of these are true:

ConditionRequirement
Customer stateHas an active subscription
UI stateHas selected a different subscription package
Target productExists in the Athena catalog
IntentCustomer is about to review or confirm a plan switch

Do not call preview for first-time purchases or free users. Those flows have no current plan to change from.

When RevenueCat is enabled, preview first reconciles the current customer from RevenueCat v2. This keeps the current plan aligned after App Store / Play purchase flows, restores, and RevenueCat TRANSFER events before Athena computes the credit impact.

Endpoint

POST /my/billing/plan-change-preview
X-API-Key: eak_...
Content-Type: application/json

The API key must be customer-scoped, meaning it was minted by the external token-exchange flow and has an attached external customer identity.

Request

Send exactly one field: the target RevenueCat product identifier.

{
"to_product_id": "pro_monthly"
}
FieldTypeNotes
to_product_idstringRequired. The product the customer wants to switch to. Exact, case-sensitive match against the workspace catalog.

Do not send current product, store, direction, price, currency, credits, dates, or replacement mode. Athena resolves those from the active subscription, workspace catalog, credit ledger, and monetization config. Preview is credit-only; real currency remains owned by the store/provider purchase flow.

If to_product_id equals the currently active subscription product, Athena returns 400 bad_request with "Already subscribed to this plan". Do not open the store purchase sheet for that no-op.

Product ID Source

to_product_id must match the product_id stored in Athena’s product catalog. That value should match the RevenueCat product’s store identifier / SKU, not the RevenueCat v2 internal product ID that starts with prod....

StoreWhat to send
AppleThe RevenueCat package product identifier, usually the App Store product ID such as com.acme.pro.monthly.
GoogleThe RevenueCat package product identifier. With Google base plans this is often the colon form such as pro:monthly, but the catalog is authoritative.

Use GET /my/catalog to list the exact product IDs available to the current customer. Athena resolves RevenueCat v2 internal prod... IDs to their store_identifier before matching the local catalog.

Response

Successful responses use Athena’s standard data envelope.

{
"data": {
"direction": "upgrade",
"store": "google",
"code": "google_charge_full_price",
"message_key": "credits.plan_change.full_charge_now",
"replacement_mode": "charge_full_price",
"google_proration_mode": "CHARGE_FULL_PRICE",
"active_credits": 120.0,
"credits_kept": 40.0,
"credits_voided": 0.0,
"credits_new_grant": 200.0,
"credits_after_change": 320.0,
"new_plan_credits_per_period": 200.0,
"bonus_days": 6,
"effective_at": "2026-06-01T10:00:00Z",
"deferred_until": null,
"period_expires_at": "2026-06-28T00:00:00Z",
"plan_changes_used": 1,
"plan_changes_max": 5,
"plan_change_retry_after_seconds": null
}
}

Classification Fields

FieldTypeMeaning
directionupgrade | downgrade | crossgradeServer-resolved relation between current and target products.
storeapple | googleStore for this plan change.
codeenumStable reflection code. Use it when branching behavior or copy.
message_keystringI18n key for user-facing copy.
replacement_modeenumAthena’s internal replacement mode used for the preview.
google_proration_modestring | nullGoogle Play replacement-mode constant to pass to purchase. null for Apple.

Credit Fields

Credit numbers are display credits, not millicredits. They use the same scale as GET /my/credits.

FieldMeaning
active_creditsSpendable credits before the change.
credits_keptCurrent-plan credits that remain spendable after the change. Usually non-zero on Google immediate changes.
credits_voidedCurrent-plan credits removed by the change. Usually non-zero on Apple immediate changes.
credits_new_grantCredits granted immediately by the new plan. 0 for deferred changes.
credits_after_changeSpendable balance immediately after the change. Use this for “new balance” copy.
new_plan_credits_per_periodNormal per-period credit allowance for the target plan.
bonus_daysExtra subscription days added by Google time/value handling. This is time, not extra credits.

Timing Fields

FieldMeaning
effective_atWhen the target plan takes effect.
deferred_untilPresent when the plan change is deferred to renewal; otherwise null.
period_expires_atCurrent billing period end. Use for renewal/deferred copy.

Rate-Limit Fields

FieldMeaning
plan_changes_usedPlan changes in the last 24 hours.
plan_changes_maxWorkspace limit per 24 hours.
plan_change_retry_after_secondsnull when purchase can continue. A number means the customer is rate-limited.

Reflection Codes

There are seven possible code values. Each maps to one message_key.

codemessage_keyMeaning
apple_full_regrantcredits.plan_change.credits_granted_nowApple immediate change. Old unused credits are voided and new plan credits are granted now.
apple_deferredcredits.plan_change.change_at_next_renewalApple downgrade deferred to renewal.
google_charge_full_pricecredits.plan_change.full_charge_nowGoogle full-price replacement mode; preview returns credit impact only.
google_charge_prorated_pricecredits.plan_change.prorated_charge_nowGoogle prorated-price replacement mode; preview returns credit impact only.
google_without_prorationcredits.plan_change.charged_at_next_renewalGoogle change without proration; preview returns credit/timing impact only.
google_with_time_prorationcredits.plan_change.time_prorationGoogle changes plan now and extends time.
google_deferredcredits.plan_change.change_at_next_renewalGoogle change deferred to renewal.

Direction is not part of code. If your copy needs different wording for upgrade, downgrade, or crossgrade, branch on direction in addition to message_key.

Suggested Copy Variables

Use the response fields as template variables. Examples:

KeySuggested copy shape
credits.plan_change.full_charge_now“Your plan switches now. Your new balance will be credits_after_change credits.”
credits.plan_change.prorated_charge_now“Your plan switches now. You keep credits_kept credits and your new balance will be credits_after_change credits.”
credits.plan_change.credits_granted_now“Your plan switches now. Your new balance will be credits_after_change credits.”
credits.plan_change.change_at_next_renewal“Your plan changes on period_expires_at.”
credits.plan_change.charged_at_next_renewal“Your new plan starts on period_expires_at.”
credits.plan_change.time_proration“Your plan switches now. Your remaining time is adjusted by the store.”

Store Purchase Flow

Apple

  1. Call preview.
  2. Render preview copy.
  3. If rate-limit fields allow the change, open the Apple purchase sheet for the target package.
  4. Ignore google_proration_mode.

RevenueCat and StoreKit handle Apple’s subscription group plan-change routing.

Google

  1. Call preview.
  2. Render preview copy.
  3. If rate-limit fields allow the change, open the Google purchase sheet for the target package.
  4. Pass the current/old product ID from RevenueCat customer state.
  5. Pass google_proration_mode from the preview response.

Do not derive the Google replacement mode from target/current plan locally. Workspace monetization config can change the selected mode, and preview is the source of truth.

Errors

ScenarioHTTPerror.codeHandling
Customer already on to_product_id400bad_requestDisable the current plan in your picker.
Target missing from catalog404not_foundRefresh catalog or report invalid product config.
No active subscription409conflictUse initial purchase flow instead.
API key has no customer identity403forbiddenRefresh customer auth/session.
Unexpected failure500internal_errorRetry or report with trace_id.

Error responses use the standard envelope:

{
"error": {
"code": "bad_request",
"message": "Already subscribed to this plan",
"timestamp": "2026-06-01T10:00:00Z",
"trace_id": "..."
}
}

Frontend Checklist

  • Fetch the available target packages from GET /my/catalog.
  • Disable the customer’s current plan in the picker.
  • Call preview with { "to_product_id": selectedPackage.product.identifier }.
  • Render message_key with response variables.
  • Show credits_after_change as the immediate balance after change.
  • Do not launch purchase when plan_change_retry_after_seconds is set.
  • For Google, pass google_proration_mode to purchase.
  • For Apple, ignore google_proration_mode.
  • Treat 409 conflict as “not a plan change” and use initial purchase flow.