Launch offer for the first 500 accounts: $10K MTR free for 24 months + $100 credit →

    Monthly Subscriptions with a 12-Month Commitment

    Sell a yearly subscription paid in 12 monthly installments: Apple’s billing plan for auto-renewable subscriptions, and Google Play’s installment plans.

    How it works

    Apple adds the plan to an existing one-year subscription: same product id, same subscription group. The customer pays one installment a month for 12 months; each payment unlocks one month. After payment 12 a new 12-month commitment starts, unless the customer cancelled.

    • Cancel stops the next commitment. The customer keeps paying, and keeps access, until the current one ends. Some regions have their own rules: Apple lets customers in Germany, Israel, South Africa and Türkiye cancel at once, with a prorated refund (see Apple’s support article).
    • Failed payment: no grace period, so access stops at once. Apple retries billing for up to 90 days; if it never succeeds, the commitment ends.
    • Upgrade to a higher tier takes effect at once and ends the commitment; the customer may receive a prorated refund of the current month. A downgrade takes effect when the commitment ends.
    • Refund of a past month leaves the commitment running; a refund of the current month ends it.

    BillDog models the plan as its own product, named after the yearly product with :monthly appended (pro.annual:monthly), the same naming RevenueCat uses. That keeps MRR, revenue by product, entitlements and offerings correct with no special cases: the product bills monthly, and its price is the installment.

    Step 1: Add the plan in App Store Connect

    1. Open your app → Subscriptions → the subscription group → your 1-year subscription.
    2. Set up the subscription’s normal (upfront) availability and prices first; the monthly plan can only be added after that.
    3. In the Availability section, choose Monthly with 12-Month Commitment → Set Up Availability, and pick the countries (never the US or Singapore). See Apple’s App Store Connect help.
    4. Set the installment price per country. The 12-month total must be between 1× and 1.5× the upfront yearly price.
    5. Introductory and promotional offers are set per billing plan; set the monthly plan’s separately.

    Nothing changes in your App Store setup in BillDog: the same In-App Purchase key and notification URL cover both plans.

    Step 2: Add the product in BillDog

    In Products, create a subscription whose App Store Product ID is the yearly id plus :monthly, for example pro.annual:monthly. BillDog stores it as a monthly subscription with 12 payments per commitment, whatever duration you pick. Attach the same entitlements as the yearly product, then add it to your offering as its own package.

    The MCP create_product tool takes the same id. If a customer buys the plan before you create the product (for example from the App Store’s own subscription page), BillDog registers it for you with App Store Product ID <yearly-id>:monthly, and copies the entitlements of the yearly product. That copy only works if the yearly product (App Store Product ID <yearly-id>) already exists in BillDog; otherwise the purchase is recorded but unlocks nothing until you attach entitlements, so create both products before you sell the plan. Virtual-currency grants are not copied: set them on the monthly product yourself if you want a grant per installment.

    Keep the bare yearly id (pro.annual) as your upfront yearly product. Both can sit in one offering.

    Step 3: Show both prices on the paywall

    Apple requires the paywall to show the monthly price and the total the customer commits to before they buy. For a commitment product, {{product.price}} is the installment, so add the total explicitly:

    {{selectedProduct.installmentPrice}}/month for {{selectedProduct.commitmentPaymentsCount}} months
    ({{selectedProduct.commitmentTotalPrice}} total, renews every {{selectedProduct.commitmentPeriod}})
    VariableExampleMeaning
    isCommitmenttrueBilled in installments over a commitment
    installmentPrice / rawInstallmentPrice£3.99 / 3.99One installment (equal to price)
    commitmentPaymentsCount12Payments in one commitment
    commitmentTotalPrice / rawCommitmentTotalPrice£47.88 / 47.88The whole commitment
    commitmentPeriod1 yearLength of the commitment

    They are available under every product prefix (selectedProduct.*, primary.*, products.0.*…) on iOS, Android and the web renderer. For a product without a commitment, isCommitment is false and the others are empty. Publish warns when a paywall sells a :monthly product but never shows commitmentTotalPrice. See the Variable System.

    Automatic “best value” badges and discount percentages: only the native iOS paywall ranks a commitment by its total over a year. The shared paywall renderer (web, Android, and the cross-platform SDKs) does not account for commitments yet and may treat the installment like a cheap monthly plan. On a paywall that sells a :monthly package, write the badge and savings copy yourself with the variables above.

    Step 4: Sell it from your app

    A paywall needs no code: its purchase button buys the selected package, and the SDK buys the :monthly package on the monthly plan. In your own UI, the :monthly package carries installmentsInfo; its product’s price, priceString and period are the installment. Where the store doesn’t sell the plan (older OS, US/SG storefront), the package is absent from the offering, and buying it directly fails with an invalid-product error. The SDK never buys it upfront instead.

    iOS (Swift)

    let offerings = try await BillDog.shared.getOfferings()
    if let pkg = offerings.first?.packages.first(where: {
           $0.storeProduct?.installmentsInfo != nil }),
       let plan = pkg.storeProduct?.installmentsInfo {
        // "£3.99/month for 12 months (£47.88 total)"
        label.text = "\(plan.installmentPriceString)/month for \(plan.commitmentPaymentsCount) months "
                   + "(\(plan.commitmentTotalPriceString) total)"
        _ = try await BillDog.shared.purchase(package: pkg)
    }
    
    // Or by id: the ":monthly" suffix selects the plan.
    _ = try await BillDog.shared.purchase(productId: "pro.annual:monthly")

    React Native / Expo

    const offerings = await BillDog.getOfferings();
    const pkg = offerings[0]?.packages.find((p) => p.product?.installmentsInfo);
    const plan = pkg?.product?.installmentsInfo;
    if (pkg && plan) {
      setLabel(`${plan.installmentPriceString}/month for ${plan.commitmentPaymentsCount} months (${plan.commitmentTotalPriceString} total)`);
      await BillDog.purchasePackage(pkg);
    }

    Flutter

    final plan = package.product?.installmentsInfo;
    if (plan != null) {
      label = '${plan.installmentPriceString}/month for ${plan.commitmentPaymentsCount} months '
              '(${plan.commitmentTotalPriceString} total)';
      await BillDog.shared.purchasePackage(package);
    }

    InstallmentsInfo has the same fields everywhere: commitmentPaymentsCount, renewalCommitmentPaymentsCount, installmentPrice/installmentPriceString, commitmentTotalPrice/commitmentTotalPriceString, installmentPeriod and commitmentPeriod (ISO 8601 in the cross-platform SDKs, e.g. P1M, P1Y). Purchase analytics events for the plan carry billing_plan: "monthly_commitment" (on Android, on purchase_completed only); other purchases carry no billing_plan. Their price is the installment actually charged.

    An App Store promoted purchase (bought from your App Store page) arrives without a plan. If you defer it, resume it as the monthly plan with continueDeferredAppStorePromoPurchase(productId:billingPlan: .monthlyCommitment).

    Reading the commitment: CustomerInfo and REST

    An entitlement unlocked by a commitment plan carries commitment; it is null for every other plan. The same object appears on each subscription in the REST API (GET /get-subscriptions):

    "commitment": {
      "expiresAt": "2027-11-02T10:00:00Z",  // end of the 12 months
      "willRenew": false,                   // a new commitment will follow
      "periodNumber": 4,                    // payment 4 …
      "totalPeriods": 12                    // … of 12
    }
    • expiresAt on the entitlement is the end of the paid month: access ends there if a payment fails. Grant access by it, never until commitment.expiresAt. commitment.expiresAt can move later if a failed payment is recovered late.
    • willRenew, on the entitlement and in commitment, is false as soon as the customer cancels, even though the remaining installments are still billed. A cancelled commitment reads like a cancelled yearly plan: “active, won’t renew, paying until commitment.expiresAt”.

    The customer page in the dashboard shows a Commitment · payment 4 of 12 · ends <date> · won’t renew badge. Its “won’t renew” reflects the commitment’s own renewal flag (commitment_will_renew).

    Lifecycle events

    Apple adds no new notification types for the plan, and BillDog adds no new event types. Every webhook event of a commitment purchase adds billing_plan: "monthly_commitment", commitment_expires_at, commitment_will_renew, commitment_period_number, commitment_total_periods and commitment_payments_remaining. Other purchases don’t get these fields. purchase.refunded carries revocation_reason whenever the store gives one, for any product.

    What happensBillDog eventIntegrations
    First paymentsubscription.started (payment 1 of 12)subscription_created
    Payments 2–12, and each payment of the next commitmentsubscription.renewedpurchase_completed
    Customer cancels mid-commitmentsubscription.canceled, once; later installments still arrive as subscription.renewedsubscription_cancelled
    Commitment ends after a cancelsubscription.expiredsubscription_expired
    Payment failssubscription.billing_issue (no grace period: access stops); subscription.expired if retry gives uppayment_failed (+ subscription_expired)
    Upgrade to a higher tiersubscription.product_changed, plus purchase.refunded with revocation_reason: "upgrade" for the prorated refundpurchase_refunded for the prorated refund (negative revenue), never a cancellation
    Refundpurchase.refunded; of the current month also subscription.expiredpurchase_refunded (+ subscription_expired)

    Journeys triggered by a cancel fire at the cancel, as for a yearly plan, while the customer may still have up to 11 paid months. For a win-back, use the Subscription Expired trigger (subscription_expired), which fires when the commitment actually ends.

    Analytics

    • Revenue is booked per installment, as it is paid: the commitment’s total is never booked upfront.
    • MRR counts one installment a month (the product bills monthly), not the yearly price divided by 12.
    • Churn is counted at the cancel, like a cancelled yearly plan, even though the remaining installments still arrive as revenue.
    • Revenue by product splits the monthly plan from the upfront yearly plan, because they are separate products.

    Google Play installment plans

    Play sells the same idea as an installment base plan on a subscription. There is nothing extra to name: the product is the base plan, subscriptionId:basePlanId as for any Play base plan (see Play Store setup). Its products carry the same installmentsInfo (BillDog’s Android SDK uses Play Billing Library 8), paywalls get the same variables, and the ledger reads Play’s installment details into commitment, events and analytics.

    Differences from Apple:

    • You choose the commitment length in Play Console, so commitmentPaymentsCount need not be 12.
    • Google sells installment plans only in some countries (at the time of writing Brazil, France, Italy and Spain); see Google’s subscriptions guide.
    • Grace period and account hold still apply to installment plans, so a failed payment does not stop access at once.

    Purchase controllers, adapters and SDK support

    • SDKs: iOS, Android, React Native, Expo and Flutter, from the first release after 1.1.1-beta.5 (see the availability note at the top), and the web paywall renderer.
    • RevenueCat adapter (iOS): buys the plan as RevenueCat’s own pro.annual:monthly product. Your app needs RevenueCat iOS 5.76 or later; the adapter accepts older versions, but the purchase then fails.
    • Your own PurchaseController (iOS): the request carries billingPlan = .monthlyCommitment; pass .billingPlanType(.monthly) to StoreKit. If you rely on the default purchase(request:), a monthly-plan request is refused unless your controller declares the billingPlans capability. If you implement purchase(request:) yourself, you receive every request and must honour billingPlan yourself, or you would sell the yearly plan upfront.
    • React Native and Flutter purchase controllers on iOS can’t declare billingPlans yet, so the monthly plan is refused there. On Android, a controller buys the installment base plan like any other base plan.
    • Capacitor plugin: buying pro.annual:monthly by product id works on iOS, but the package’s installmentsInfo isn’t exposed to JavaScript, so build the price copy from paywall variables.
    • Superwall adapter: not supported.

    Testing

    • Sandbox: use a sandbox tester whose country is outside the US and Singapore. Set it in App Store Connect → Users and Access → Sandbox → the tester → Country or Region, then sign the tester out and back in on the device. Sandbox renews the 12 payments in accelerated time.
    • StoreKit configuration file: Xcode 26.5+ adds the plan under the subscription’s Billing Plan. A file written by an older Xcode cannot hold it. Note that Xcode’s local StoreKit testing ignores a cancel of a commitment, so test cancels in sandbox.
    • Purchases made against a local StoreKit file are Xcode-signed. BillDog refuses to record them, by design, so they never reach your analytics.

    Was this page helpful?

    Or rate this page:

    We use cookies and process your data to provide our services. By continuing, you agree to our Terms and Privacy Policy.