Usage Billing for a Shopify App: Define the Unit Before You Wire the Charge
The hard part of usage billing is rarely calling the charging API. It is defining what counts as one billable result. A vague "number of operations" mixes previews, skips, failed retries, and real writes together, leaving merchants unable to predict a bill and developers unable to reconcile one.
Shopify currently offers two billing paths. Pick one before designing the metering model, because they handle caps very differently.
| Shopify App Pricing | Billing API (manual pricing) | |
|---|---|---|
| Position | The preferred approach for public apps | Still supported, for existing apps and pricing models the other does not cover |
| Usage configuration | Define a usage meter under Pricing in the Partner Dashboard, report billing events through the App Events API | GraphQL mutations such as appSubscriptionCreate |
| Per-cycle cap | Usage caps are not currently supported | cappedAmount limits what a merchant is billed within a 30-day cycle |
Shopify App Pricing has several hard constraints that shape the implementation directly: at most five active usage meters per plan, at most six pricing tiers per meter, usage charges must be billed monthly (they cannot be combined with yearly-only plans), and after a merchant uninstalls the app you have 24 hours to submit any remaining billing events — after that the billing period closes and new events are rejected. See Set up usage charges; for the cappedAmount behavior, see usage-based subscriptions with the Billing API.
The platform handles the subscription and the bill. Metering, cost estimation, and idempotent reporting stay with the app.
Define the unit first
A good unit satisfies four conditions:
- The merchant can understand it before the run.
- The app can compute it exactly after the run.
- A retry does not produce a second valid unit.
- It tracks the underlying cost reasonably well.
For cross-store sync, "each resource or field value successfully written to the target store" is a workable unit. You still have to decide each of these cases:
| Outcome | Billable | Why |
|---|---|---|
| Diff preview only | No | Nothing in the target store changed |
| Skipped because already identical | No | No new write was produced |
| Write failed | No | The merchant did not get the promised result |
| Write succeeded | Yes | A verifiable result exists in the target |
| Retried after a failure, then succeeded | Once | A result should only be confirmed once |
| Deliberately overwritten again by the user | Per product rule | State it in the pricing description |
Do not bill from a task engine's done count. Many engines mark "already identical" and "existing mapping found" as done, and done is not the same as a billable write having happened.
Estimate and settle from the same set
The pre-run quote should come from the same authoritative diff:
candidate resources
→ filter out identical, non-executable, and missing-dependency items
→ the projected write set
→ projected units and maximum cost
The final charge is then based on the set of writes that actually succeeded. An estimate is not the final bill, but both must classify by the same rules, or the user sees "estimated 20, charged 87" with no way to tell where the gap came from.
The confirmation screen should show at least:
- Projected units for this run.
- Remaining quota in the plan — under Shopify App Pricing this is maintained and enforced by the app, since the platform provides no usage cap.
- Possible overage cost.
- Whether failures and skips are billed.
- Clear cancel and confirm actions.
Do not block a task with a notice bar that has no visible action. Confirming a charge is a decision the user makes, and it deserves a stable screen they can come back to.
The internal ledger is the source of truth
The app should write to its own usage ledger first, then send pending events to Shopify:
UsageLedger
- shopId
- billingCycleId
- operationId
- itemKey
- units
- status: pending | reported | failed
- providerRecordId
- createdAt
A workable uniqueness boundary is (operationId, itemKey, meter), or an idempotency key with equivalent semantics. When a job restarts, a request times out, or a worker retries, one business result must still produce exactly one valid ledger row.
The ledger separates "the business succeeded" from "Shopify accepted the report":
- The target write succeeds.
- Write the ledger row under the idempotency key.
- Report to Shopify asynchronously.
- Store the platform record ID or the failure reason.
- Re-report pending rows on a schedule and reconcile against Shopify's billing state.
That reporting window is not open-ended: under Shopify App Pricing, once a merchant uninstalls you have 24 hours to submit the remaining billing events. The catch-up job has to treat the uninstall event as a trigger and drain that store's pending rows immediately, rather than waiting for the next fixed-interval scan.
If you call the charging API at the end of the business request instead, a timeout leaves two bad outcomes: a write that was never billed, or a retry that billed twice.
Cycles, quotas, and trials
A quota is not a number that accumulates forever; it is bound to a billing cycle. Under Shopify App Pricing the platform will not cap anything for you, so overage protection is entirely the app's job. Under the Billing API, cappedAmount limits what is billed in the 30-day cycle, and raising it needs the merchant's approval; the app should still track the remaining balance locally so it can warn in advance.
For a long job that spans cycles, either lock the billing rules at the start or assign each item to the cycle it actually completed in. Either is defensible, as long as the rule is published in advance and applied consistently.
Trials need their own rules:
- Whether the trial has a unit ceiling.
- Whether exceeding it pauses the work or starts charging.
- Whether converting to paid grants a fresh cycle quota.
- How a job created during the trial but finished afterwards is handled.
"Free during the trial" answers none of these. It states a price, not a usage limit or which cycle the work belongs to.
Protecting cost
Free previews should not mean unlimited resource use. For previews that require heavy API querying, computation, and database access, add:
- Separate rate-limit keys per capability, so a file listing and a cost quote do not share one cooldown.
- A per-store concurrency limit.
- A per-scan ceiling and pagination.
- Queue capacity and timeouts.
- Budget or anomaly alerts.
Present these as service protection, not as units quietly folded into the sync charge.
Reconciliation and acceptance
- Retrying one successful item repeatedly leaves exactly one charge in both the ledger and Shopify.
- Skips, failures, and previews produce no billing records.
- A timed-out report can be safely re-sent.
- Units for a job spanning cycles are assigned according to the published rule.
- After an uninstall, that store's pending ledger is either drained within the 24-hour window or explicitly abandoned.
- Plan upgrades, cancellation, and trial expiry move entitlements and ledger cycles together.
- The merchant can see used, remaining, reset time, and the projected cost of this run.
- The app can trace a business record to a platform charge, and work back from any bill line to the records behind it.
Shopify's billing capability answers "how this reaches the merchant's invoice". The metering model answers "why this amount is owed". Settle the second before you write the price list or integrate the API.