
#What Broke When a Real App Adopted a 12-Month Commitment Plan
We migrate our own apps against InfluTo before we ship anything to customers. Kaloria was the guinea pig for Apple's monthly-subscription-with-12-month-commitment plan type, and it's the app that caught this bug before it reached a single external integration.
The scenario: a subscriber on a 12-month commitment plan hits their third renewal. DID_RENEW fires, as expected. The transaction validates. Our Store-Direct handler processes it as a new commissionable event — because on the surface, it looks like one. New transactionId, new purchase timestamp, new receipt to validate against the App Store Server API.
The problem is that this subscriber already triggered a period-3 milestone bonus once, in an earlier test pass on the same commitment cycle. The naive dedup logic — keyed only on transactionId — had no way to know that. It was about to pay a milestone bonus twice on a subscription that had renewed exactly once. In sandbox, caught before production. In production, that's a support ticket and a clawback conversation with an influencer who did nothing wrong.
#Reading the New Payload: billingPlanType and TransactionCommitmentInfo Field by Field
Apple added two fields to JWSTransactionDecodedPayload — and mirrored versions to JWSRenewalInfoDecodedPayload — specifically to describe commitment-plan billing. They landed in the App Store Server API changelog dated April 27, 2026, alongside equivalents in App Store Server Notifications.
billingPlanType tells you how the subscription bills. For a standard subscription it's absent or defaults to up-front billing. For a commitment plan it's set to monthly — the subscriber is billed monthly, at the monthly rate, for the length of the commitment, rather than paying the full term up front.
TransactionCommitmentInfo is the object that actually tells you where the subscriber is in that commitment. It carries period and duration data you use to figure out "which renewal is this, within which commitment."
Here's a redacted, illustrative reconstruction of what a period-3 renewal transaction looks like — this is not verbatim Apple documentation, just the shape you'll see:
| Field | Example value | Notes |
|---|---|---|
transactionId | "2000000998877" | Unique per transaction — changes every renewal |
originalTransactionId | "2000000112233" | Stable across the entire subscription lifetime |
billingPlanType | "MONTHLY" | Marks this as a 12-month commitment plan |
commitmentInfo.commitmentPeriodCount | 3 | Which billing period within the current commitment |
commitmentInfo.commitmentDuration | "P12M" | Total length of the commitment |
The field to fixate on is commitmentPeriodCount. It's the only thing in the payload that tells you where you are in the cycle without re-deriving it from timestamps.
#The Double-Count: How a Renewal Transaction Almost Paid Two Influencers
The mechanism is simple once you see it, and easy to miss if you don't: a DID_RENEW notification for a commitment plan renewal carries a new transactionId but keeps the same originalTransactionId as every prior period in that commitment. That's by design — Apple uses originalTransactionId to link the whole chain of periods together.
Our original Store-Direct dedup logic predated commitment plans entirely. It was built for ordinary auto-renewing subscriptions, where the assumption "one transactionId equals one billable event equals one commission" holds fine. Under that assumption, every renewal transaction that clears validation gets its transactionId added to a seen-set, and the payout pipeline moves on.
Commitment plans break the second half of that assumption without breaking the first. transactionId is still unique per renewal — Apple never reuses it. So the naive key never technically collides on the renewal itself. The collision happens one layer up, in milestone logic: our bonus trigger was watching for "has this originalTransactionId crossed into period 3," and on a retry or a delayed webhook replay, it saw period 3 twice under two different transactionId values, because nothing in our key distinguished "period 3, first delivery" from "period 3, redelivered notification."

#The Fix: A Compound Dedup Key Using originalTransactionId + CommitmentInfo
The fix is one extra field concatenated into the key we already had. Instead of deduplicating on transactionId alone, or even on originalTransactionId alone (which would wrongly collapse every period into one event), the key becomes originalTransactionId + commitmentPeriodCount.
This works because originalTransactionId identifies the subscription instance, and commitmentPeriodCount identifies where in the 12-period commitment this specific transaction sits. Together they're stable across notification retries and redeliveries, but distinct across genuinely new periods.
The condition, in pseudocode:
key = transaction.originalTransactionId + "-" + transaction.commitmentInfo.commitmentPeriodCount
if key in seenCommitmentEvents:
skip() // redelivery or replay of an already-processed period
else:
seenCommitmentEvents.add(key)
processRecurringCommission(transaction)
if isMilestonePeriod(transaction.commitmentInfo.commitmentPeriodCount):
fireMilestoneBonus(transaction)
The decision tree is short: validate the JWS, extract originalTransactionId and commitmentPeriodCount, build the compound key, check membership, and only then touch the ledger. Everything downstream — recurring commission, milestone check — sits behind that gate, not in front of it.
If you're extending your own Store-Direct handler, don't key on transactionId for any commitment-plan logic. Reserve it for idempotency at the HTTP-delivery layer only — Apple can and does redeliver notifications, and transactionId is fine for catching literal duplicate webhook POSTs. It's the wrong key for "has this billing period already been paid out."
#Milestone Bonuses Across 12 Periods Without Re-Firing
A milestone bonus configured for, say, period 6 of a 12-period commitment has to fire exactly once, tied to commitmentPeriodCount == 6 — not to "the sixth renewal event we happened to process." Those sound identical until a notification gets redelivered or a subscriber upgrades mid-commitment and the event stream gets messier than a clean linear sequence.
Once the compound key is in place, milestone logic becomes a lookup rather than a counter: on each validated transaction, read commitmentPeriodCount, check if it matches a configured milestone period, and fire if — and only if — that compound key hasn't been seen. There's no incrementing counter to drift out of sync with reality, because the period number comes straight from Apple's payload instead of being inferred from how many times your webhook handler has been called.
Across a full 12-period commitment, this means a campaign with milestones at, say, periods 3, 6, and 12 fires those three bonuses exactly three times total, regardless of how many redeliveries or retries happen underneath.
#Sandbox Verification: What We Tested Before Shipping
We ran the fix against three scenarios in Sandbox before it touched production traffic, simulating twelve renewal events per run to cover the full commitment.
The clean renewal case — twelve sequential periods, no retries — confirms the baseline: one recurring commission and each configured milestone bonus fired exactly once, in order.
The plan-upgrade-mid-commitment case checks what happens when a subscriber changes tier partway through the 12 periods. This matters because an upgrade can affect which originalTransactionId subsequent transactions carry, and we needed to confirm our key logic doesn't silently orphan periods after the switch.
The refund-then-repurchase case is the nastiest one: a refund followed by a new purchase can produce a fresh originalTransactionId entirely. We verified the compound key correctly treats that as a new commitment — new commissions, milestone counters reset — rather than trying to stitch it onto the old chain.

#If You're on RevenueCat Instead of Store-Direct
Only one validation path can be active per app — that's a hard constraint, not a preference. If your app runs on the RevenueCat webhook path, this entire class of bug is someone else's problem to have already solved: RevenueCat parses and stores billingPlanType, renewalBillingPlanType, and commitmentInfo automatically as of their WWDC26 update, so you're not writing custom backend logic to extract these fields from raw JWS payloads.
That said, "RevenueCat parses the fields" and "your webhook consumer deduplicates on the right key" are two different claims. If you're building milestone logic on top of RevenueCat subscriber attributes, the same principle applies: key on the stable subscription identity plus the commitment period, not on the webhook event ID alone.

#Transferable Checklist for Store-Direct Teams
Before enabling a 12-month commitment plan on any Store-Direct integration, run through this in order:
- Confirm your JWS decoding logic reads
billingPlanTypeandcommitmentInfo— don't assume older parsing code picks up new fields silently. - Replace any
transactionId-only dedup key used for commission or milestone logic withoriginalTransactionId + commitmentPeriodCount. - Audit milestone triggers specifically — recurring commission logic is more forgiving of duplicate events than one-time bonus logic.
- Sandbox-test a full 12-period sequence, plus a mid-commitment upgrade and a refund-then-repurchase case.
- Verify refund/repurchase produces a new
originalTransactionIdin your test data before assuming milestone counters reset correctly. - If you later migrate to RevenueCat, confirm which path is active — running both simultaneously is what causes double-reporting in the first place.
Does Google Play's subscription API have an equivalent to Apple's TransactionCommitmentInfo?
The verified evidence bank here covers only Apple's App Store Server API additions. If you're running Google Play commitment-style plans, don't assume field parity — check Google's own subscription API documentation directly before porting this dedup logic across platforms.
If a subscriber cancels partway through a 12-month commitment, does an already-paid milestone bonus get clawed back?
This is a policy decision for your own commission rules, not something Apple's payload dictates. The dedup key described here only controls whether a bonus fires, not what happens to it after cancellation.
Do existing subscribers who purchased before the April 2026 API update retroactively get billingPlanType populated?
Nothing in the verified changelog entries indicates retroactive backfill for pre-existing transactions. Treat the fields as present going forward from subscriptions actually enrolled in a commitment plan, and don't build logic that assumes older transaction records will suddenly carry them.