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
- Customer selects a target package in your plan picker.
- Call
POST /my/billing/plan-change-previewwith onlyto_product_id. - Render the returned
message_key, credit fields, timing, and rate limit state. - If
plan_change_retry_after_secondsis notnull, do not open the store purchase sheet. - For Google, pass the returned
google_proration_modeinto the purchase call. Do not infer the replacement mode locally. - For Apple, ignore
google_proration_mode; it is alwaysnull.
When to Call It
Call preview when all of these are true:
| Condition | Requirement |
|---|---|
| Customer state | Has an active subscription |
| UI state | Has selected a different subscription package |
| Target product | Exists in the Athena catalog |
| Intent | Customer 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-previewX-API-Key: eak_...Content-Type: application/jsonThe 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"}| Field | Type | Notes |
|---|---|---|
to_product_id | string | Required. 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....
| Store | What to send |
|---|---|
| Apple | The RevenueCat package product identifier, usually the App Store product ID such as com.acme.pro.monthly. |
The 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
| Field | Type | Meaning |
|---|---|---|
direction | upgrade | downgrade | crossgrade | Server-resolved relation between current and target products. |
store | apple | google | Store for this plan change. |
code | enum | Stable reflection code. Use it when branching behavior or copy. |
message_key | string | I18n key for user-facing copy. |
replacement_mode | enum | Athena’s internal replacement mode used for the preview. |
google_proration_mode | string | null | Google 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.
| Field | Meaning |
|---|---|
active_credits | Spendable credits before the change. |
credits_kept | Current-plan credits that remain spendable after the change. Usually non-zero on Google immediate changes. |
credits_voided | Current-plan credits removed by the change. Usually non-zero on Apple immediate changes. |
credits_new_grant | Credits granted immediately by the new plan. 0 for deferred changes. |
credits_after_change | Spendable balance immediately after the change. Use this for “new balance” copy. |
new_plan_credits_per_period | Normal per-period credit allowance for the target plan. |
bonus_days | Extra subscription days added by Google time/value handling. This is time, not extra credits. |
Timing Fields
| Field | Meaning |
|---|---|
effective_at | When the target plan takes effect. |
deferred_until | Present when the plan change is deferred to renewal; otherwise null. |
period_expires_at | Current billing period end. Use for renewal/deferred copy. |
Rate-Limit Fields
| Field | Meaning |
|---|---|
plan_changes_used | Plan changes in the last 24 hours. |
plan_changes_max | Workspace limit per 24 hours. |
plan_change_retry_after_seconds | null 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.
code | message_key | Meaning |
|---|---|---|
apple_full_regrant | credits.plan_change.credits_granted_now | Apple immediate change. Old unused credits are voided and new plan credits are granted now. |
apple_deferred | credits.plan_change.change_at_next_renewal | Apple downgrade deferred to renewal. |
google_charge_full_price | credits.plan_change.full_charge_now | Google full-price replacement mode; preview returns credit impact only. |
google_charge_prorated_price | credits.plan_change.prorated_charge_now | Google prorated-price replacement mode; preview returns credit impact only. |
google_without_proration | credits.plan_change.charged_at_next_renewal | Google change without proration; preview returns credit/timing impact only. |
google_with_time_proration | credits.plan_change.time_proration | Google changes plan now and extends time. |
google_deferred | credits.plan_change.change_at_next_renewal | Google 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:
| Key | Suggested 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
- Call preview.
- Render preview copy.
- If rate-limit fields allow the change, open the Apple purchase sheet for the target package.
- Ignore
google_proration_mode.
RevenueCat and StoreKit handle Apple’s subscription group plan-change routing.
- Call preview.
- Render preview copy.
- If rate-limit fields allow the change, open the Google purchase sheet for the target package.
- Pass the current/old product ID from RevenueCat customer state.
- Pass
google_proration_modefrom 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
| Scenario | HTTP | error.code | Handling |
|---|---|---|---|
Customer already on to_product_id | 400 | bad_request | Disable the current plan in your picker. |
| Target missing from catalog | 404 | not_found | Refresh catalog or report invalid product config. |
| No active subscription | 409 | conflict | Use initial purchase flow instead. |
| API key has no customer identity | 403 | forbidden | Refresh customer auth/session. |
| Unexpected failure | 500 | internal_error | Retry 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_keywith response variables. - Show
credits_after_changeas the immediate balance after change. - Do not launch purchase when
plan_change_retry_after_secondsis set. - For Google, pass
google_proration_modeto purchase. - For Apple, ignore
google_proration_mode. - Treat
409 conflictas “not a plan change” and use initial purchase flow.