Product phases

A product phase is a pricing segment with a defined duration. Phases let you model offers like "free for 14 days, then $29/mo for 3 months, then $39/mo forever" without juggling separate products and subscription migrations. The phase carries the price (or a discount off a base price), a count of billing periods it lasts, and an ordinal that sequences phases within a product or subscription.

Frame uses one underlying primitive for two surfaces:

  • ProductPhase — phases attached to a Product. Acts as a template: when you create a subscription against the product, the product's phases are copied onto the subscription as SubscriptionPhases. Edit the template; future subscriptions inherit the change; existing ones do not.
  • SubscriptionPhase — phases attached to a Subscription. Instance-specific. Edit a subscription's phase to adjust pricing for that one customer; the underlying product is unaffected.

The model is polymorphic (phaseable_id / phaseable_type); the API exposes them as two endpoints with parallel shapes.

Pricing types

A phase carries one of two pricing types:

  • static — fixed price in cents (amount_cents). Billed at this amount for the phase duration regardless of the product's base price.
  • relative — a percentage discount off the product's base price (discount_percentage). Useful for "50% off for first 3 months" patterns where you want the discount to track the base price if it changes later.

Pick static when the phase amount is a deliberate target (e.g., "$0 trial," "$10 intro price"). Pick relative when the phase is a discount off whatever the current product price is.

Fields

curl --request POST \
  --url https://api.framepayments.com/v1/product_phases \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "product_id": "<product_id>",
    "name": "Free trial",
    "ordinal": 1,
    "pricing_type": "static",
    "amount_cents": 0,
    "period_count": 14
  }'

The interesting fields:

  • ordinal — sequence within the product/subscription. Must be a positive integer (≥1). Phases activate in ordinal order; the lowest ordinal is the first phase the customer sits in. Ordinals must be unique within a phaseable.
  • name — display name. Surfaces in the dashboard and on subscription change logs.
  • pricing_typestatic or relative (see above).
  • amount_cents — for static phases. Required when pricing_type: static.
  • discount_percentage — for relative phases. Required when pricing_type: relative. Range 0-100.
  • period_count — how many billing periods this phase lasts. Must be ≥1; no "open-ended" phase value exists. For "rest of subscription" semantics, set period_count to a value larger than the subscription will ever reach (e.g., 999) and treat it as effectively unbounded.
  • started_at — populated by Frame when the phase becomes active on a subscription. Don't set on create.

Phase transitions

A subscription tracks which phase is currently active via current_phase and phase_started_at. When a billing cycle completes:

  1. Frame checks whether cycles_completed_in_phase >= period_count.
  2. If yes, transition to the next phase (next-higher ordinal).
  3. If there is no next phase, stay on the current one indefinitely.

Important caveat: phase transitions don't auto-fire on a background timer in V1. The transition is checked when a subscription event runs (renewal, manual sync). For most subscriptions this happens naturally on the billing cycle; if you're orchestrating subscription state outside the renewal flow, you may need to explicitly trigger the phase check via a subscription update.

Common shapes

Free trial → paid. Two phases: a static $0 phase with period_count: 1 (one billing cycle of free), then a large-period_count phase at the product's base price (e.g., period_count: 999).

Intro pricing → standard. Two phases: a static discounted price for N cycles, then a large-period_count phase at standard pricing.

Annual ramp. Three phases for an enterprise plan: discounted year 1, partial discount year 2, full price year 3+ (set the last phase's period_count high).

Cycle-bounded coupon. A relative 50% phase with period_count: 6 followed by a high-period_count phase at full price. Note: for short-term promotional discounts at checkout, coupons + promotion codes are usually the right surface — phases are more appropriate when the discount is part of the product's offering.

Editing phases on an active subscription

You can edit a subscription's phases via the subscription-phase endpoints. Two patterns:

  • Bulk update: PATCH /v1/subscriptions/<id>/phases/bulk_update accepts a full phase array and overwrites. Use when you're restructuring the subscription's ramp end-to-end.
  • Per-phase update: PATCH /v1/subscriptions/<id>/phases/<phase_id> for surgical changes.

Editing phases doesn't retroactively re-bill prior cycles. Changes take effect on the next phase transition or the current phase's pricing as of the next renewal.

Relationships

  • Productphaseable_type = "Product", phaseable_id = <product_id>. Acts as a reusable template.
  • Subscriptionphaseable_type = "Subscription", phaseable_id = <subscription_id>. Instance-specific.

When a subscription is created against a product, the product's phases are copied (not referenced) onto the subscription as SubscriptionPhases. From that point, the subscription's phases evolve independently — useful, because it means changing a product's phases doesn't disrupt existing customers.

Gotchas

Symptom: you added a new phase to a product, but existing subscriptions didn't pick it up. Why: phases are copied on subscription create, not linked by reference. Existing subscriptions have their own phase set. Fix: this is by design. If you want existing subscriptions to migrate to a new phase structure, update them individually via the subscription-phase endpoints.

Symptom: phase doesn't transition when you expect. Why: phase transitions happen on subscription events (renewal, update), not on a wall-clock timer. If the subscription hasn't had an event since the prior period closed, the transition hasn't fired yet. Fix: trigger a subscription update or wait for the next renewal cycle; the transition will catch up.

Symptom: a relative 50% phase isn't applying the discount you expected. Why: relative discounts are calculated off the product's current default_price, not a frozen historical price. If the product's price has moved since the subscription was created, the discount math has too. Fix: either use a static phase to fix the dollar amount or accept that relative is dynamic — that's its purpose.

Reference

For the full API surface, see POST/v1/products/{product_id}/phases and POST/v1/subscriptions/{subscription_id}/phases.