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.
installmentsInfo, buying the :monthly package, the paywall variables in your app) is not in a published SDK yet: it ships in the first release after 1.1.1-beta.5. Check your SDK’s changelog for the commitment-plan entry before you rely on it.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
- Open your app → Subscriptions → the subscription group → your 1-year subscription.
- Set up the subscription’s normal (upfront) availability and prices first; the monthly plan can only be added after that.
- 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.
- Set the installment price per country. The 12-month total must be between 1× and 1.5× the upfront yearly price.
- 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}})| Variable | Example | Meaning |
|---|---|---|
isCommitment | true | Billed in installments over a commitment |
installmentPrice / rawInstallmentPrice | £3.99 / 3.99 | One installment (equal to price) |
commitmentPaymentsCount | 12 | Payments in one commitment |
commitmentTotalPrice / rawCommitmentTotalPrice | £47.88 / 47.88 | The whole commitment |
commitmentPeriod | 1 year | Length 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
}expiresAton the entitlement is the end of the paid month: access ends there if a payment fails. Grant access by it, never untilcommitment.expiresAt.commitment.expiresAtcan move later if a failed payment is recovered late.willRenew, on the entitlement and incommitment, isfalseas 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 untilcommitment.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 happens | BillDog event | Integrations |
|---|---|---|
| First payment | subscription.started (payment 1 of 12) | subscription_created |
| Payments 2–12, and each payment of the next commitment | subscription.renewed | purchase_completed |
| Customer cancels mid-commitment | subscription.canceled, once; later installments still arrive as subscription.renewed | subscription_cancelled |
| Commitment ends after a cancel | subscription.expired | subscription_expired |
| Payment fails | subscription.billing_issue (no grace period: access stops); subscription.expired if retry gives up | payment_failed (+ subscription_expired) |
| Upgrade to a higher tier | subscription.product_changed, plus purchase.refunded with revocation_reason: "upgrade" for the prorated refund | purchase_refunded for the prorated refund (negative revenue), never a cancellation |
| Refund | purchase.refunded; of the current month also subscription.expired | purchase_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
commitmentPaymentsCountneed 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:monthlyproduct. 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 defaultpurchase(request:), a monthly-plan request is refused unless your controller declares thebillingPlanscapability. If you implementpurchase(request:)yourself, you receive every request and must honourbillingPlanyourself, or you would sell the yearly plan upfront. - React Native and Flutter purchase controllers on iOS can’t declare
billingPlansyet, 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:monthlyby product id works on iOS, but the package’sinstallmentsInfoisn’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: