We manage our whole infrastructure as code, and wanted our Paddle Billing catalog — products, prices, discounts, webhook config — to work the same way instead of being the one thing still managed by hand through a dashboard. Existing tooling didn't cover enough of what we actually needed, lifecycle actions for subscriptions and refunds especially, so we built our own.
terraform-provider-paddle manages Paddle Billing's catalog — products, prices, discounts, discount groups, notification settings — plus six lifecycle actions (refunds/credits, subscription cancel/pause/resume/charge, notification replay), lookup data sources for subscriptions/transactions/customers/events, and a couple of newer Terraform capabilities: an ephemeral resource for a webhook secret that never touches state, and resource identity + list-block support for bulk-discovering existing infrastructure via terraform query.
This post isn't a feature tour, though — it's about one specific decision that shaped how the whole thing got built: every resource and data source is verified end-to-end against Paddle's real sandbox API before each release, not just tested against mocks. Here's a real bug from that process that explains why.
The problem with mocking someone else's API
Every API client eventually encodes assumptions about the wire format — what fields exist, what shape they're nested in, what a status value actually means. When those assumptions are right, mocks are great: fast, free, deterministic, no network flakiness. When they're wrong, a hand-written mock just encodes the same wrong assumption and "confirms" it forever. A test suite built entirely on mocks can be green for months while quietly testing the wrong thing.
Here's a concrete example. A transaction's line items don't carry a flat price_id field — Paddle nests a full price object under each line item, and the ID you actually want is price.id. A mock built around the flat-field assumption would have exercised the same code path, returned a plausible-looking response, and passed — while the real code silently compared against an empty string every time it ran against the real API.
The fix wasn't just correcting that one field. Paddle publishes a proper machine-readable OpenAPI spec for their whole API, auto-generated from their own backend whenever it changes — so this provider now vendors a copy of it and runs a forward-only contract check on every test run: for every field a client struct actually reads or writes, does that field still exist in Paddle's real API, at the same nested path, with a structurally compatible shape? It's deliberately one-directional — the provider intentionally leaves plenty of Paddle's API surface unmodeled, so checking "does the provider cover everything Paddle has" would just flag every deliberate scope decision as false-positive drift. Checking "does what the provider does model still match the spec" is the direction that actually matches the bug history.
Newer Terraform features, and why they showed up here
Ephemeral resources. A webhook signing secret is real, sensitive data that's genuinely needed at apply time — but there's no reason it should sit in a state file forever. Sensitive: true on a schema attribute only redacts CLI/log output; it does nothing to state itself, which still stores the real value in plaintext. This provider added an ephemeral resource specifically so that secret can be fetched fresh on every apply and never written to state at all, while keeping the old (now-deprecated, still fully working) attribute in place for anyone already depending on it.
Resource identity + list resources. terraform query (Terraform 1.14+) turns out to hard-depend on resource identity being implemented on the target resource first — a list result's identity is required, and it's built from that schema. Once that's wired up for a resource, bulk-discovering everything of that type already sitting in a Paddle account — and generating import config for it — becomes a first-class Terraform workflow instead of something you'd have to script by hand against the raw API.
Try it
- Terraform Registry: registry.terraform.io/providers/vivantel/paddle
- Repo: github.com/vivantel/terraform-provider-paddle
Feedback and issues welcome — especially anywhere the schema doesn't match real Paddle behavior. That's exactly the kind of thing this whole approach is built to catch, and it's more reliable with more real accounts exercising it.

Top comments (0)