What Paddle doesn't tell you about implementing metered billing

How Phare implements calendar-month metered billing on Paddle using zero-value subscriptions, one-time charges, and a homemade invoice grace period.

At the end of last year, Phare started to grow organically, acquiring a few new customers every month. With the growing MRR and a mix of individuals and companies registering from everywhere in the world, the reality of VAT compliance started to hit me. I needed a merchant-of-record, and since I was creating a standalone company for Phare anyway, it was the right timing to migrate from a clean state with minimal accounting work.

After evaluating a few options, I chose to go with Paddle. This meant rewriting the existing billing flow from Stripe.

"Set up in minutes" might be a stretch

Paddle advertises support for usage-based billing, but implementing it correctly while providing a billing flow with great user experience turned out to be more challenging than I thought. Most of the complexity was self-inflicted, but I think there's plenty of learnings from Phare's implementation that are worth sharing.

Phare's ideal billing system

Phare's pricing model is a free tier and a paid plan starting at 5 euros per month. The paid plan includes base quotas for monitoring events, phone alerts, and AI-generated incident summaries. Customers can opt in to additional quotas and pay for the overages on top.

That's two packets of instant ramen after taxes, per month

Paddle does not have the same metered billing primitives as Stripe, no meter API, no invoice grace periods, no automatic usage aggregation. There's also no way of charging an amount every month without relying on a subscription. The whole metered billing system is based on a single feature: one-time charges.

The one-time charge implementation allows us to add pretty much anything to a subscription's invoice. It offers good freedom but isn't well adapted for SaaS usage-based billing and requires a few workarounds for a proper implementation.

Since I was rewriting billing from scratch, I also wanted invoices to follow calendar months rather than arbitrary anniversary dates. I just find it to be more practical, need to know how much you spent in February: check the invoice for February, simple.

The perfect implementation had to cover the following features:

  • Fixed-price monthly subscription, with calendar month billing cycles
  • Metered usage billing with proper handling of cancellation
  • Good-looking checkout flow without surprises for customers

Crafting the base subscription

When initializing the checkout form, it is possible to pass a transaction ID previously created by API, this allows the best flexibility and usage of all the options provided by the transaction API.

The billing cycle is part of the subscription definition and can be set when creating a transaction. But it turns out that the billing cycle parameters are ignored when creating a transaction for a regular subscription. This was my first major roadblock.

The first solution I found was to modify the billing cycle with an API call after the user completes the checkout flow, but this has two major drawbacks that create a lot of confusion for the users:

  • The Paddle checkout iframe shows the date of the next invoice, which will change immediately after the user completes checkout.
  • The user receives an email after completing checkout that their subscription billing cycle has changed.

Sane defaults from Paddle to prevent abuse, but this prevents me from implementing the payment flow I wanted.

After a few days of tinkering with the API, I found a feature called zero-value subscriptions. It's not well documented and can only be created from the API, but when creating a checkout transaction for a zero-value subscription, the billing cycle parameters are enforced correctly. The zero-value approach also removes cost and renewal date information on the checkout iframe, allowing me to show custom information to customers.

Here is what the transaction creation looks like using the PHP SDK:

$startAt = now()->startOfMonth()->addHours(12);

// Edge case on the first day of the month before noon,
// we need to use the previous billing cycle.
if ($startAt->isAfter(now())) {
    $startAt = $startAt->subMonth();
}

$endAt = $startAt->copy()->endOfMonth()->addHours(12);

$paddle->transactions->create(new CreateTransaction(
    items: [new TransactionCreateItemWithPrice(
        price: new TransactionNonCatalogPrice(
            description: 'Monthly Scale plan subscription',
            unitPrice: new Money('0', CurrencyCode::EUR()), // Zero-value
            productId: 'pro_01abc123',
            billingCycle: new TimePeriod(Interval::Month(), 1),
            taxMode: TaxMode::External(),
        ),
        quantity: 1,
    )],
    customerId: $customerId,
    currencyCode: CurrencyCode::EUR(),
    collectionMode: CollectionMode::Automatic(),
    billingPeriod: new TransactionTimePeriod(
        startsAt: $startAt->toDateTime(),
        endsAt: $endAt->toDateTime(),
    )
));


To allow an invoice grace period (explained in a further section of this article), the billing period is set to start on the 1st of the month at 12:00 UTC and ends on the last day at 11:59 UTC. There is a subtle edge case: if someone subscribes on the 1st before noon, startOfMonth()->addHours(12) is in the future, so we fall back to the previous month's billing cycle.

Due to Phare's billing specificities, the created transaction can be cached for a given customer for the duration of the month, ensuring page refreshes or visits to the checkout page do not take a performance hit from the third-party API call.

Moving out of zero-value subscriptions

Zero-value subscription allows the most flexibility but comes with a major issue for usage-based billing: cancellations are immediate and there is no way to bill for usage that already happened during the current period. There's only one way to fix this, convert the zero-value subscription to a regular paid subscription.

This can be done with an API call to the subscription update API by attaching my regular plan's catalogue price to the subscription:

$paddle->subscriptions->update($subscriptionId, new UpdateSubscription(
    items: [new SubscriptionItems($plan->paddlePriceId, quantity: 1)],
    prorationBillingMode: ProrationBillingMode::ProratedImmediately(),
));

If a customer subscribes on the 25th of the month, they only pay for the remaining 5-6 days, not the full ongoing month. With more than 7 days remaining, the prorated amount is charged immediately. Otherwise, it's collected on the next invoice to avoid charging a negligible amount right after checkout.

This update notifies the customer that they subscribed to a new plan and triggers a payment with the prorated amount. Since they just completed checkout a few seconds ago, receiving a payment notification feels natural.

DIY invoice grace period

Because I control when billing happens as a consequence of the calendar month requirement, I can also control when usage is finalized. Stripe has built-in invoice grace periods for this, with Paddle, you roll your own.

At the end of each billing cycle, I need to finish collecting usage data for the month and report it to Paddle before the invoice is generated. Usage keeps changing until the very last moment, so I need a window where usage is frozen and I can safely report the final numbers.

Paddle freezes invoices 30 minutes before processing payments. Since I set the billing time of all subscriptions to the 1st of the month at 12:00 UTC, I have a comfortable window to report usage before the freeze.

Here is the timeline that runs on the 1st of every month:

  • 00:00 UTC: Fresh monthly usage records are created, previous month usage is frozen.
  • 01:00 UTC: Overage costs are calculated for the previous month and submitted to Paddle as one-time charges.
  • 11:30 UTC: Paddle stops accepting changes to the upcoming invoices.
  • 12:00 UTC: Paddle charges the customers.

Each overage type (monitoring events, phone alerts, AI generations) is submitted as a separate line item with its own Paddle product ID, so invoices stay readable and customers see exactly what each charge is for. Paddle fully handles tax calculation and currency conversion. All I need is to send the raw cost in my base currency.

$paddle->subscriptions->createOneTimeCharge($subscriptionId, new CreateOneTimeCharge(
    effectiveFrom: $effectiveFrom,
    items: [
        new SubscriptionItemsWithPrice(
            price: new SubscriptionNonCatalogPrice(
                description: 'Monitoring events - 200,000 units',
                name: 'Extra monitoring events',
                productId: 'pro_abc123',
                taxMode: TaxMode::External(),
                unitPrice: new Money('500', CurrencyCode::EUR()), // €5.00
            ),
            quantity: 1,
        ),
    ],
));

There's once again a subtle detail here: if a subscription is set to be cancelled at the end of the billing cycle, Paddle will discards any one-time charges attached to the next billing period. The only way to charge the customer for additional usage is to trigger a charge before the invoice is frozen.

For this I added a simple check in the reporting job that switches to an immediate charge if the subscription has an ends_at date (meaning it has been cancelled):

$effectiveFrom = $subscription->ends_at
    ? SubscriptionEffectiveFrom::Immediately()
    : SubscriptionEffectiveFrom::NextBillingPeriod();

Trade carefuly with rate limit

Paddle's API rate limit is 240 requests per minute, which is generous but still needs to be taken into account when planning your homemade grace period, you need enough time to report usage for all your customers without hitting the rate limit.

To handle this, I dispatch background jobs with a 1-second delay, ensuring I stay well under the limit:

$delay = 0;

Organization::whereHas('activeSubscription')
    ->lazyById()
    ->pluck('id')
    ->each(fn (int $id) => 
      ReportOrganizationQuotaUsageJob::dispatch($id)->delay($delay++)
    );

Laravel is doing all the heavy lifting for me here

One job per second result in 60 requests per minute, leaving 180 requests for other operations. With a 10h grace period, Phare can scale to about 36,000 organizations before hitting the invoice freeze window. I hope to get rate limited one day as I would be rolling in money, but will probably burn out before that.

Fixing the checkout flow

Paddle offers two options to embed the checkout form: overlay or inline. I wanted to replicate Stripe's checkout experience, with the payment information on the left and the checkout form on the right, and Paddle's inline option allows doing just that.

We already tackled the hardest part of creating a good checkout flow by crafting a transaction based on a zero-value subscription, preventing Paddle from showing any pricing information that will be changed with proration.

To show accurate pricing based on the customer location and tax obligations, we can listen to events using Paddle.js eventCallback hook.

Paddle fires checkout.customer.created and checkout.customer.updated events when the customer enters or changes their billing details inside the iframe. I intercept those events and send the customer's address and business information to a server-side endpoint that calls Paddle's price preview API to compute the exact tax rate and currency to use for their location.

Paddle.Initialize({
  token: paddleClientSideToken,
  checkout: {
    settings: { displayMode: 'inline', frameTarget: 'checkout-container' }
  },
  eventCallback: (event) => {
    if (['checkout.customer.created', 'checkout.customer.updated'].includes(event.name)) {
      updatePricing(event.data)
    }
  }
})

async function updatePricing(data) {
  if (!data.customer?.id || !data.customer.address?.id) return

  const { currency, taxRate } = await post('/checkout/preview-price', {
    customerId: data.customer.id,
    addressId: data.customer.address.id,
    businessId: data.customer.business?.id ?? null,
  })

  // Update the pricing display with the correct currency and tax rate
}

On the server side, the endpoint calls Paddle's price preview API to get the correct currency and tax rate for the customer, which is all the frontend needs to display accurate pricing.

Checkout page with a billing address in Germany

Customers can now see all important information to subscribe with confidence and avoid any surprises. After payment completion, customers are redirected to a temporary page, waiting for Paddle to fire the subscription.created webhook. This is where the zero-value subscription gets converted to a real paid plan, as described in the previous section.

What I still could not pull off

I wanted to implement full end-of-month billing, like AWS or most hosting providers. You use resources during the month, and you pay for everything at the end. No upfront charge. This is technically possible with the zero-value subscription by billing everything through one-time charges at the end of the cycle. But the major risk of never getting paid after cancellation is too big. Paddle adds cancellation links to all subscription communications, and that reintroduces all the problems with instant cancellation and no way to bill outstanding usage.

The other feature I wanted and isn't yet supported is a rechargeable credit balance that lets customers prepay credits on their own timeline. This model is becoming increasingly popular with AI tools, and for good reason: it removes all fears of huge bills for customers, and usage is always paid in advance, which cancels all collection risk that makes metered billing tricky in the first place. Paddle simply doesn't have the feature, there's no credit or wallet primitive in their API to build on.

Conclusion

Most of this complexity is on me for wanting calendar month cycles and custom proration logic, none of which are standard requirements. The one area where I genuinely wish Paddle did more is handling ongoing usage at cancellation time, that is a real gap that affects anyone doing metered billing, not just my weird setup.

Overall, I'm really happy with how it turned out, Phare customers have been billed through Paddle for the past 3 months, and I only received good feedback about the implementation. The main benefit being, of course, not having to worry about VAT compliance. The foundation is solid and allows me to add new billable features easily, and all my interactions with Paddle support team have been great.

Phare's revenue, costs, and metrics are all public at phare.io/open, you can see exactly how this billing setup performs in production. If you're building metered billing on Paddle, this should save you the weeks of API tinkering it took me.