> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dodopayments.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Pricing as Code with Terraform

> Manage products, prices, meters, credits, discounts, and webhooks as code with the Dodo Payments Terraform provider. Review each pricing change in a pull request and apply it from CI.

> **[Terraform Registry](https://registry.terraform.io/providers/dodopayments/dodopayments/latest)**
>
> Provider documentation for each resource, data source, and action.

> **[Example Repository](https://github.com/dodopayments/terraform-dodopayments-example)**
>
> A complete AI SaaS catalog with plan, apply, drift, and end-to-end workflows.

## Overview

The Dodo Payments Terraform provider keeps your pricing in configuration files in your Git repository. You define products, prices, meters, credits, discounts, entitlements, and webhook endpoints in code. A pricing change then goes through the same steps as a code change: a pull request, a reviewed plan, and an apply from CI.

This gives your pricing four properties that dashboard edits do not have:

* **Review before release.** `terraform plan` shows each product, price, and discount that a change creates, updates, or archives. A teammate approves the plan before customers see the new price.
* **Version history.** Each pricing change is a commit with an author, a reviewer, and a reason. To undo a change, revert the commit and apply again.
* **One catalog in each mode.** The same configuration builds your catalog in test mode and in live mode. You test a pricing change end to end in test mode, then apply the same commit to live mode.
* **Drift detection.** A scheduled `terraform plan` finds changes that someone made in the dashboard, so the repository stays the source of truth.

The provider manages the catalog: the objects that define what you sell and at what price. Customers, subscriptions, payments, and refunds are not in the provider. Your application creates them at runtime with the [API or an SDK](https://docs.dodopayments.com/developer-resources/dodo-payments-sdks.md).

## What You Can Manage

Each catalog object has a resource. Each resource also has a data source for one object, a data source for a list of objects, and a list resource for `terraform query`.

| Catalog Object | Terraform Resource | Feature Page |
| - | - | - |
| Products with one-time, subscription, or usage-based prices | `dodopayments_product` | [Products](https://docs.dodopayments.com/features/products.md) |
| Fixed prices in other currencies | `dodopayments_product_localized_price` | [Localized Pricing](https://docs.dodopayments.com/features/localized-pricing.md) |
| Product collections with plan change rules | `dodopayments_product_collection` | [Product Collections](https://docs.dodopayments.com/features/product-collections.md) |
| Add-ons | `dodopayments_addon` | [Add-ons](https://docs.dodopayments.com/features/addons.md) |
| Meters | `dodopayments_meter` | [Meters](https://docs.dodopayments.com/features/usage-based-billing/meters.md) |
| Credit entitlements | `dodopayments_credit_entitlement` | [Credit-Based Billing](https://docs.dodopayments.com/features/credit-based-billing.md) |
| Entitlements, such as feature flags and license keys | `dodopayments_entitlement` | [Entitlements](https://docs.dodopayments.com/features/entitlements/introduction.md) |
| Discount codes | `dodopayments_discount` | [Discounts](https://docs.dodopayments.com/features/discount-codes.md) |
| Brands | `dodopayments_brand` | [Multi-Brand Setup](https://docs.dodopayments.com/features/multi-brands.md) |
| Webhook endpoints | `dodopayments_webhook` | [Webhooks](https://docs.dodopayments.com/developer-resources/webhooks.md) |

The provider also has these Terraform objects:

* The ephemeral resource `dodopayments_webhook_secret` reads the signing secret of a webhook endpoint without a copy in the state.
* The actions `dodopayments_product_unarchive`, `dodopayments_addon_unarchive`, `dodopayments_meter_unarchive`, `dodopayments_product_collection_unarchive`, and `dodopayments_credit_entitlement_undelete` restore an archived or deleted object.

## Getting Started

You need Terraform 1.12 or later and a test mode API key. Create the key in the dashboard under **Developer → API Keys** while test mode is on.

> [!WARNING]
>
> The provider connects to live mode when you do not set `environment`. Set `environment = "test_mode"` until you are ready to change your live catalog.

**1. Configure the Provider**

Create a file named `versions.tf`:

```hcl versions.tf theme={null}
terraform {
  required_version = ">= 1.12"

  required_providers {
    dodopayments = {
      source  = "dodopayments/dodopayments"
      version = "~> 0.2"
    }
  }
}

provider "dodopayments" {
  environment = "test_mode"
}
```

**2. Set the API Key**

The provider reads the API key from the `DODO_PAYMENTS_API_KEY` environment variable:

```bash theme={null}
export DODO_PAYMENTS_API_KEY="your_test_mode_api_key"
```

**3. Define a Product**

Create a file named `products.tf`. Prices are in the smallest currency unit, so `4900` is \$49.00:

```hcl products.tf expandable theme={null}
resource "dodopayments_product" "pro_monthly" {
  name         = "Pro (Monthly)"
  description  = "Pro plan, billed monthly."
  tax_category = "saas"

  price = {
    type                         = "recurring_price"
    currency                     = "USD"
    price                        = 4900
    tax_inclusive                = false
    payment_frequency_count      = 1
    payment_frequency_interval   = "Month"
    subscription_period_count    = 20
    subscription_period_interval = "Year"
    trial_period_days            = 14
  }

  metadata = {
    managed_by = "terraform"
  }
}

output "pro_monthly_product_id" {
  value = dodopayments_product.pro_monthly.id
}
```

For the meaning of the subscription period, see [Subscriptions](https://docs.dodopayments.com/features/subscription.md).

**4. Plan and Apply**

Download the provider, review the plan, and create the product:

```bash theme={null}
terraform init
terraform plan
terraform apply
```

`terraform apply` prints the new product ID. The product is also in the dashboard, in test mode.

## Modeling a Pricing Catalog

A real catalog has tiers, billing cycles, and rules between them. In Terraform, you write the pricing model once as data and generate the objects from it. To change a price, you change one number, and the plan shows each object that the change affects.

This example builds three tiers in two billing cycles, puts them in one product collection, and adds a launch discount for the annual plans:

```hcl catalog.tf expandable theme={null}
locals {
  # Prices are in cents.
  tiers = {
    starter = { title = "Starter", monthly = 1900, annual = 19000 }
    pro     = { title = "Pro", monthly = 4900, annual = 49000 }
    scale   = { title = "Scale", monthly = 14900, annual = 149000 }
  }
  tier_order = ["starter", "pro", "scale"]

  cycles = {
    monthly = { interval = "Month", label = "Monthly" }
    annual  = { interval = "Year", label = "Annual" }
  }

  # 3 tiers x 2 cycles = 6 plans, keyed "pro_annual" and so on.
  plans = {
    for pair in setproduct(keys(local.tiers), keys(local.cycles)) :
    "${pair[0]}_${pair[1]}" => {
      tier     = pair[0]
      cycle    = pair[1]
      title    = local.tiers[pair[0]].title
      monthly  = local.tiers[pair[0]].monthly
      price    = pair[1] == "monthly" ? local.tiers[pair[0]].monthly : local.tiers[pair[0]].annual
      interval = local.cycles[pair[1]].interval
      label    = local.cycles[pair[1]].label
    }
  }
}

resource "dodopayments_product" "plan" {
  for_each = local.plans

  name         = "${each.value.title} (${each.value.label})"
  tax_category = "saas"

  price = {
    type                         = "recurring_price"
    currency                     = "USD"
    price                        = each.value.price
    tax_inclusive                = false
    payment_frequency_count      = 1
    payment_frequency_interval   = each.value.interval
    subscription_period_count    = 20
    subscription_period_interval = "Year"
  }

  lifecycle {
    precondition {
      condition     = each.value.cycle == "monthly" || each.value.price <= floor(each.value.monthly * 12 * 0.9)
      error_message = "An annual plan must cost at least 10% less than 12 monthly payments."
    }
  }
}

resource "dodopayments_product_collection" "plans" {
  name = "Plans"

  groups = [
    for cycle in ["monthly", "annual"] : {
      group_name = local.cycles[cycle].label
      products = [
        for tier in local.tier_order : { product_id = dodopayments_product.plan["${tier}_${cycle}"].id }
      ]
    }
  ]

  effective_at_on_upgrade             = "immediately"
  proration_billing_mode_on_upgrade   = "prorated_immediately"
  effective_at_on_downgrade           = "next_billing_date"
  proration_billing_mode_on_downgrade = "do_not_bill"
}

resource "dodopayments_discount" "launch" {
  name   = "Launch: 20% off annual plans"
  code   = "LAUNCH20"
  type   = "percentage"
  amount = 2000 # basis points

  restricted_to       = [for tier in local.tier_order : dodopayments_product.plan["${tier}_annual"].id]
  expires_at          = "2027-06-30T23:59:59Z"
  subscription_cycles = 1
}
```

This configuration shows patterns that the dashboard cannot give you:

* **One source for each price.** The discount and the collection refer to the products, not to product IDs. When you add a tier, the collection and the discount get the new plan in the same apply.
* **Pricing rules as checks.** The `precondition` stops the plan when an annual price does not save at least 10%. Use `precondition` blocks and `check` blocks for each rule that your pricing must keep.
* **Reviewable changes.** When you change `pro.monthly` from `4900` to `5900`, the plan shows one change to `dodopayments_product.plan["pro_monthly"]` before anything changes.

The [example repository](https://github.com/dodopayments/terraform-dodopayments-example) extends this model with meters, a shared credit balance with rollover and overage, a usage-based Enterprise plan, localized EUR and INR prices, add-ons, credit packs, and three discount types.

## Reviewing Pricing Changes With GitOps

With GitOps, the main branch of your repository is the record of your live catalog. Nobody edits pricing in the dashboard. Each change is a pull request, and CI applies the change after the merge.

```mermaid theme={null}
%%{init: {"sequence": {"mirrorActors": false}}}%%
sequenceDiagram
    autonumber
    actor Dev as You
    participant Git as Git repository
    participant CI
    participant Dodo as Dodo Payments

    rect rgba(120, 120, 140, 0.12)
    Note over Dev,Dodo: Pull request
    Dev->>Git: Open a pull request with a pricing change
    Git->>CI: Run terraform plan
    CI->>Dodo: Read the current catalog
    CI-->>Git: Post the plan summary
    end

    rect rgba(120, 120, 140, 0.12)
    Note over Dev,Dodo: Merge to main
    Dev->>Git: Approve the plan and merge
    Git->>CI: Run terraform apply
    CI->>Dodo: Create, update, or archive objects
    end

    rect rgba(120, 120, 140, 0.12)
    Note over Dev,Dodo: Each night
    CI->>Dodo: Compare the catalog with main
    alt Drift found
        CI-->>Git: Open a drift issue
    else No drift
        CI-->>Git: Close the open drift issue
    end
    end
```

A GitOps setup for the provider has these parts:

| Part | What It Does |
| - | - |
| Remote state | Keeps the Terraform state in a shared backend with locking, such as Google Cloud Storage, Amazon S3, or HCP Terraform. Never commit the state file. |
| Plan on pull request | Runs `terraform plan` for each pull request and posts the summary as a comment. Reviewers approve the plan, not only the code. |
| Apply on merge | Runs `terraform apply` on the main branch only. The job plans again, saves the plan to a file, and applies that file, so the apply does what the job summary shows. |
| Drift check | Runs `terraform plan -detailed-exitcode` on a schedule. Exit code `2` means that the dashboard differs from the main branch. |
| API key in CI secrets | Stores `DODO_PAYMENTS_API_KEY` in the secret store of your CI system. Never write the key in the configuration. |

This GitHub Actions job applies the catalog after a merge to the main branch:

```yaml .github/workflows/apply.yml expandable theme={null}
name: apply

on:
  push:
    branches: [main]

concurrency:
  group: apply-catalog
  cancel-in-progress: false

jobs:
  apply:
    runs-on: ubuntu-latest
    env:
      DODO_PAYMENTS_API_KEY: ${{ secrets.DODO_PAYMENTS_API_KEY }}
      TF_IN_AUTOMATION: "1"
    steps:
      - uses: actions/checkout@v6
      - uses: hashicorp/setup-terraform@v4
      - run: terraform init -input=false
      - run: terraform plan -input=false -out=tfplan
      - run: terraform apply -input=false tfplan
```

The job plans again after the merge, so its plan can differ from the plan in the pull request. This occurs when the dashboard or another merge changed the catalog after the review. Compare the job summary with the reviewed plan, and use the drift check to find dashboard changes early.

The `concurrency` group makes a second apply wait for the first one. The job also needs credentials for your state backend. The example repository uses Workload Identity Federation, so CI has no long-lived cloud key. It also has the plan, drift, and end-to-end workflows.

> [!TIP]
>
> Give the plan job read-only access to the state, and run its plan with `-lock=false`, because a state lock needs write access on most backends. Give write access only to the main branch. Then a pull request can show a plan, but only a merge can change your catalog.

## Promoting Changes From Test Mode to Live Mode

Each API key works in one mode, and object IDs are different in each mode. Keep one state for each mode, and refer to resources instead of writing IDs in the configuration. Then the same configuration builds the same catalog in both modes.

Set the mode with a variable or with the `DODO_PAYMENTS_ENVIRONMENT` environment variable:

```hcl theme={null}
variable "environment" {
  type = string

  validation {
    condition     = contains(["test_mode", "live_mode"], var.environment)
    error_message = "Set environment to test_mode or live_mode."
  }
}

provider "dodopayments" {
  environment = var.environment
}
```

To release a pricing change:

**1. Apply in Test Mode**

Apply the commit with the test mode API key and the test mode state.

**2. Test the Change**

Create a checkout for the changed products in test mode. Use the [test cards](https://docs.dodopayments.com/miscellaneous/testing-process.md) to complete a purchase.

**3. Apply in Live Mode**

Apply the same commit with the live mode API key and the live mode state.

## Detecting Dashboard Changes

A change in the dashboard does not update your configuration. The next `terraform apply` reverts each dashboard change to an argument that your configuration sets. To find these changes before an apply does, run a plan on a schedule:

```bash theme={null}
terraform plan -input=false -lock=false -detailed-exitcode
```

The command returns `0` when the catalog matches the configuration and `2` when it does not. The example repository runs this command each night. When the result is `2`, it opens an issue with the summary of the plan. When a later run returns `0`, it closes the issue.

To keep a dashboard change, copy it into the configuration in a pull request. To discard it, run the apply workflow again.

## Importing Existing Products

You can bring objects that you created in the dashboard under Terraform management. Import an object with an `import` block and its ID:

```hcl theme={null}
import {
  to = dodopayments_product.pro_monthly
  identity = {
    id = "pdt_0NXyZ1aBcD2eFgH3iJkLm"
  }
}
```

With Terraform 1.14 or later, `terraform query` lists the objects in your account and writes the configuration for you. Create a file with the `.tfquery.hcl` extension:

```hcl catalog.tfquery.hcl theme={null}
list "dodopayments_product" "all" {
  provider         = dodopayments
  include_resource = true
}
```

Then generate a `resource` block and an `import` block for each product:

```bash theme={null}
terraform query -generate-config-out=generated.tf
```

Move the blocks that you want into your configuration, run `terraform plan`, and confirm that the plan imports the objects with no other changes.

## Keeping the Webhook Secret Out of State

The ephemeral resource `dodopayments_webhook_secret` reads the signing secret of a webhook endpoint during a run. Terraform does not write an ephemeral value to the plan or the state. Pass the secret to a write-only argument, such as the secret data of a secret manager:

```hcl theme={null}
resource "dodopayments_webhook" "billing" {
  url          = "https://example.com/webhooks/dodo"
  filter_types = ["payment.succeeded", "subscription.active", "subscription.cancelled"]
}

ephemeral "dodopayments_webhook_secret" "billing" {
  webhook_id = dodopayments_webhook.billing.id
}

resource "google_secret_manager_secret_version" "webhook_signing" {
  secret                 = google_secret_manager_secret.webhook_signing.id
  secret_data_wo         = ephemeral.dodopayments_webhook_secret.billing.secret
  secret_data_wo_version = 1
}
```

Your webhook receiver reads the secret from the secret manager. To verify webhook signatures, see [Webhooks](https://docs.dodopayments.com/developer-resources/webhooks.md).

## Deleting a Resource

When you remove a resource from the configuration, Terraform destroys it. The provider archives some objects and deletes others. An action can restore some of them. The table shows what happens to each object.

| Resource | When Terraform Destroys the Resource | Action That Restores the Object |
| - | - | - |
| `dodopayments_product` | The provider archives the product. | `dodopayments_product_unarchive` |
| `dodopayments_product_localized_price` | The provider archives the localized price. | None |
| `dodopayments_product_collection` | The provider deletes the collection. | `dodopayments_product_collection_unarchive` |
| `dodopayments_addon` | The provider deletes the add-on. | `dodopayments_addon_unarchive` |
| `dodopayments_meter` | The provider archives the meter. | `dodopayments_meter_unarchive` |
| `dodopayments_credit_entitlement` | The provider deletes the credit entitlement. | `dodopayments_credit_entitlement_undelete` |
| `dodopayments_entitlement` | The provider deletes the entitlement. | None |
| `dodopayments_discount` | The provider deletes the discount. | None |
| `dodopayments_webhook` | The provider deletes the webhook endpoint. | None |
| `dodopayments_brand` | The provider archives the brand. | None |

> [!WARNING]
>
> The API cannot restore an archived brand. Before you remove a `dodopayments_brand` resource, make sure that you do not need the brand again.

To prevent an accidental destroy of a product that customers subscribe to, add `prevent_destroy` to its `lifecycle` block.

## Related

> **[Products](https://docs.dodopayments.com/features/products.md)**
>
> Product types, prices, and settings that the provider manages.

> **[Product Collections](https://docs.dodopayments.com/features/product-collections.md)**
>
> Group plans and set the rules for upgrades and downgrades.

> **[Credit-Based Billing](https://docs.dodopayments.com/features/credit-based-billing.md)**
>
> Credit balances with rollover, expiry, and overage.

> **[Test Mode vs Live Mode](https://docs.dodopayments.com/miscellaneous/test-mode-vs-live-mode.md)**
>
> How the two modes keep their objects and API keys apart.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.