Build subscriptions

A subscription bills a customer or account on a recurring cadence. The integration shape is short: create a product, attach phases if your offer has trial or intro pricing, then create a subscription against the customer's stored payment method. Frame handles the cycle-by-cycle invoicing and charging; your application listens for the webhooks and reacts.

This guide walks through the full setup. For mid-flight changes to an active subscription, see upgrade or downgrade a subscription.

Prerequisites

RequirementDetails
Frame secret keyFor server-side product + subscription creation.
Customer or Account recordThe payer. Pre-create via the Customers or Accounts API, or attach to the subscription create call.
Stored payment methodThe card or bank account to charge each cycle. Collected via frame-js or attached via the PaymentMethods API.

1. Create the product

A subscription needs a recurring product. The product carries the cadence, the default price, and any reusable phases.

curl --request POST \
  --url https://api.framepayments.com/v1/products \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "name": "Pro Plan",
    "description": "Monthly access to the pro tier",
    "default_price": 2900,
    "purchase_type": "recurring",
    "recurring_interval": "monthly"
  }'

Save the returned id — you'll reference it when creating subscriptions.

For multi-tier offers (monthly vs annual, or basic vs pro), create one product per tier. Don't try to model tiers within a single product — Frame's products are designed around one product = one billing shape.

2. (Optional) Attach phases for ramps or trials

If your subscription has a trial period, intro pricing, or a multi-step ramp, attach phases to the product. Each phase covers some number of billing cycles before the next phase takes over.

Free trial → paid example:

# Phase 1: free trial, one cycle
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": 1
  }'

# Phase 2: paid, long-running (set period_count high for "rest of subscription")
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": "Paid",
    "ordinal": 2,
    "pricing_type": "static",
    "amount_cents": 2900,
    "period_count": 999
  }'

Phases on the product are templates — they're copied onto each subscription at create time. Edits to the product's phases after creation only affect new subscriptions; existing ones keep their copied phases. See product phases for the full mechanics.

3. Create the subscription

With the product (and optional phases) in place, create a subscription:

curl --request POST \
  --url https://api.framepayments.com/v1/subscriptions \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "customer": "<customer_id>",
    "product": "<product_id>",
    "default_payment_method": "<payment_method_id>",
    "currency": "USD",
    "description": "Pro Plan — monthly",
    "proration_behavior": "create_prorations"
  }'

Owner choice: pass either customer or account, not both. For B2C subscriptions (individual end-user billing), use customer. For B2B (team / org billing), use account. This decision affects which webhook events fire and stays fixed for the subscription's lifetime.

The subscription is created in pending and immediately attempts activation:

  • Phase 0's first charge runs against the payment method. If it's a $0 trial phase, the charge is symbolic (a $0 invoice).
  • On successful first cycle, status transitions to active.
  • On failure, status transitions to incomplete — fix the payment method and re-trigger.

4. Apply a discount (optional)

Subscriptions don't accept promotion_codes directly on create. Two paths to attach a discount:

Option A — discount via phase. If the discount is part of the offer ("first 3 months 50% off"), bake it into the product's phases:

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": "Intro 50% off",
    "ordinal": 1,
    "pricing_type": "relative",
    "discount_percentage": 50,
    "period_count": 3
  }'

Then add a phase for the standard price at ordinal 2 (with a high period_count).

Option B — promo code at invoice time. If the discount is campaign-driven (a customer enters a code at checkout), apply it on the subscription's first invoice or via the invoice the subscription generates each cycle. The simplest path: create the invoice manually with the code, then attach a payment method to settle it.

curl --request POST \
  --url https://api.framepayments.com/v1/invoices \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "customer": "<customer_id>",
    "collection_method": "auto_charge",
    "payment_method": "<payment_method_id>",
    "promotion_codes": ["LAUNCH20"],
    "line_items": [
      { "product": "<product_id>", "quantity": 1 }
    ]
  }'

Choose by intent: phases for structural discounts ("part of the product"), promo codes for marketing-driven discounts ("part of the campaign").

5. React to webhooks

The subscription lifecycle emits webhook events your application needs to handle. Subscribe to:

For customer-owned subscriptions (B2C):

  • customer.subscription.activated — initial activation succeeded
  • customer.subscription.renewal.processing — a renewal cycle is charging
  • customer.subscription.renewal.completed — cycle settled
  • customer.subscription.renewal.failed — cycle failed; needs attention
  • customer.subscription.past_due / customer.subscription.unpaid — escalating non-payment
  • customer.subscription.canceled / customer.subscription.terminated — end of life

For account-owned subscriptions (B2B), the same events fire with subscription.* prefixes (no customer. prefix). Subscribe to both prefixes if your platform supports both shapes.

// Webhook handler
switch (event.type) {
  case 'customer.subscription.activated':
  case 'subscription.activated':
    grantAccess(event.data.customer || event.data.account);
    break;

  case 'customer.subscription.renewal.failed':
  case 'subscription.renewal.failed':
    notifyPayerToUpdatePaymentMethod(event.data);
    break;

  case 'customer.subscription.canceled':
  case 'customer.subscription.terminated':
  case 'subscription.canceled':
  case 'subscription.terminated':
    revokeAccess(event.data.customer || event.data.account);
    break;
}

6. Handle failed renewals

When a renewal cycle's invoice fails to collect:

  1. renewal_status flips to failed on the subscription.
  2. The corresponding *.subscription.renewal.failed webhook fires.
  3. The subscription itself transitions to past_due (then escalates to unpaid if recovery doesn't happen).

Frame doesn't run dunning logic automatically — there's no built-in retry schedule for failed subscription charges. Your application owns recovery:

  • Prompt the customer to update their payment method.
  • Once they've updated, you can re-issue the invoice or trigger a subscription update to retry.
  • If the customer abandons recovery, your platform's policy decides when to cancel.

A reasonable recovery shape:

day 0: invoice fails → notify customer
day 3: re-attempt → if still failing, escalate notice
day 7: re-attempt → if still failing, mark subscription canceled, revoke access

Schedule the retries from your application; Frame won't do it for you in V1.

7. Cancel when needed

POST /v1/subscriptions/<id>/cancel ends the subscription immediately:

curl --request POST \
  --url https://api.framepayments.com/v1/subscriptions/<id>/cancel \
  --header "Authorization: Bearer $FRAME_SECRET_KEY"

The subscription transitions to canceled right now — no grace period, no end-of-cycle behavior. If you want "cancel at end of current period," implement the scheduling at your application layer: store the requested cancel-at date, fire the cancel call when the period boundary hits.

Frame doesn't auto-refund the unused portion of a partially-consumed period. If your policy requires a partial refund, issue a refund against the most recent successful invoice's charge.

Common variations

B2B subscriptions. Use account instead of customer. Webhooks fire under subscription.* (no customer. prefix). Otherwise the shape is identical.

Quantity-based pricing. Set quantity on the subscription. Invoices generate with line_item.quantity × product.default_price per cycle.

Mid-cycle changes. See upgrade or downgrade a subscription for the proration mechanics.

Net-terms invoicing (B2B). Skip subscriptions entirely; create invoices manually with collection_method: request_payment and net_terms set. The customer receives the invoice and pays via the surfaced payment link.

Gotchas

Symptom: subscription stuck in incomplete after create. Why: initial charge failed. Fix: verify the payment method is valid (GET /v1/payment_methods/<id>); update if needed; trigger a re-attempt by updating the subscription's default_payment_method.

Symptom: you tried to pass promotion_codes on subscription create and it errored. Why: the subscription endpoint doesn't accept promo codes. Fix: either use phases (structural discount) or apply the promo code at invoice time (campaign discount). See step 4.

Symptom: renewal didn't fire on the day you expected. Why: renewals are cycle-driven, not wall-clock-driven. Check the subscription's current_period_end — that's when the next cycle starts. Fix: nothing to fix; it's working as intended.

Symptom: webhook event name you expected didn't fire. Why: you subscribed to customer.subscription.* but the subscription is account-owned (or vice-versa). Fix: subscribe to both prefixes, or check the subscription's owner type and route accordingly.

Next steps