PaddleBilling

Elixir library for Paddle Billing API v2.

Dual-write CRUD, two-way sync with drift detection, webhook verification, checkout flow, and installable admin panel.

Features

Requirements

Installation

1. Add as a dependency

Add paddle_billing to your list of dependencies in mix.exs:

def deps do
  [
    {:paddle_billing, github: "safemyprivacy0-bit/paddle_billing"}
  ]
end

2. Configure environment variables

export PADDLE_BILLING_ENVIRONMENT=sandbox    # or "production"
export PADDLE_BILLING_API_KEY=pdl_sdbx_apikey_xxx
export PADDLE_BILLING_CLIENT_TOKEN=test_xxx  # for Paddle.js checkout
export PADDLE_BILLING_SIGNING_SECRET=pdl_ntfset_xxx

3. Add config to config/runtime.exs

config :paddle_billing, :config,
  environment: System.get_env("PADDLE_BILLING_ENVIRONMENT", "sandbox"),
  api_key: System.get_env("PADDLE_BILLING_API_KEY"),
  client_token: System.get_env("PADDLE_BILLING_CLIENT_TOKEN"),
  signing_secret: System.get_env("PADDLE_BILLING_SIGNING_SECRET")

4. Create Ecto schemas and migration

Copy the integration layer to your project:

lib/your_app/billing.ex              # Context (public API)
lib/your_app/billing/paddle_product.ex
lib/your_app/billing/paddle_price.ex
lib/your_app/billing/paddle_discount.ex
lib/your_app/billing/sync.ex

Copy and run the migration:

mix ecto.migrate

5. Set up webhooks

Copy the webhook controller and plug:

lib/your_app_web/controllers/paddle_webhook_controller.ex
lib/your_app_web/plugs/paddle_webhook_signature.ex

Add the webhook route to your router.ex:

scope "/webhooks", YourAppWeb do
  pipe_through :paddle_webhook
  post "/paddle", PaddleWebhookController, :handle
end

Add the raw body caching to endpoint.ex for the webhook path.

6. Install admin panel (optional)

mix paddle_billing.install

Options:

mix paddle_billing.install --web-module MyAppWeb --context-module MyApp.Billing
mix paddle_billing.install --no-routes  # skip route injection, print instructions

The installer:

After installing, register the JS hook in assets/js/hooks/index.js:

import PaddleCheckout from "./paddle_checkout"

export default {
  // ...existing hooks
  PaddleCheckout,
}

7. Set up auto-sync (optional)

Copy the Oban worker:

lib/your_app/workers/paddle_sync_worker.ex

Add to Oban crontab in config/config.exs:

config :your_app, Oban,
  queues: [default: 10],
  plugins: [
    {Oban.Plugins.Cron,
     crontab: [
       {"0 */6 * * *", YourApp.Workers.PaddleSyncWorker}
     ]}
  ]

Usage

API Client (direct)

# List products from Paddle API
{:ok, products} = PaddleBilling.Products.list_all()

# Create a product
{:ok, product} = PaddleBilling.Products.create(%{
  "name" => "Pro Plan",
  "tax_category" => "standard"
})

# Get a price
{:ok, price} = PaddleBilling.Prices.get("pri_01h...")

Billing Context (dual-write)

All operations write to Paddle API and local database atomically:

alias YourApp.Billing

# Create product (Paddle + local DB)
{:ok, product} = Billing.create_product(%{
  "name" => "Pro Plan",
  "tax_category" => "standard",
  "plan_level" => "starter",
  "app_role" => "subscription"
})

# Create price for product
{:ok, price} = Billing.create_price(product, %{
  "amount" => 2900,           # in cents ($29.00)
  "currency_code" => "USD",
  "billing_cycle_interval" => "month",
  "billing_cycle_frequency" => 1,
  "description" => "Monthly Pro"
})

# Update product
{:ok, updated} = Billing.update_product(product, %{"name" => "Pro Plan v2"})

# Archive
{:ok, archived} = Billing.archive_product(product)

# List from local DB (fast, no API call)
products = Billing.list_products(status: "active", app_role: :subscription)
prices = Billing.list_prices_for_product(product.paddle_id)
discounts = Billing.list_discounts(status: "active")

Sync

# Pull everything from Paddle -> local DB
{:ok, results} = Billing.sync_all_from_paddle()

# Pull one resource type
{:ok, products} = Billing.sync_from_paddle(:products)
{:ok, prices} = Billing.sync_from_paddle(:prices)
{:ok, discounts} = Billing.sync_from_paddle(:discounts)

# Detect drift (compare local checksums vs Paddle API)
{:ok, drift} = Billing.detect_drift(:products)
# => [{%PaddleProduct{}, :in_sync}, {%PaddleProduct{}, :drifted}, ...]

# Reconcile with strategy
{:ok, results} = Billing.reconcile(:products, strategy: :paddle_wins)   # Paddle overwrites local
{:ok, results} = Billing.reconcile(:products, strategy: :local_wins)    # Local pushes to Paddle
{:ok, results} = Billing.reconcile(:products, strategy: :newest_wins)   # Newer timestamp wins

Checkout

# Create a checkout session (Paddle Transaction)
{:ok, transaction_id} = Billing.create_checkout(
  ["pri_01h..."],                          # price IDs
  custom_data: %{"account_id" => 123}      # passed to webhooks
)

# Preview pricing without creating a transaction
{:ok, preview} = Billing.preview_checkout(
  ["pri_01h..."],
  currency_code: "EUR",
  discount_id: "dsc_01h..."
)

# Get params for Paddle.js frontend
params = Billing.checkout_params(["pri_01h..."],
  success_url: "https://example.com/success",
  display_mode: "overlay"
)

# Get client token and environment for frontend
Billing.client_token()   # => "test_xxx"
Billing.environment()    # => :sandbox

Frontend checkout route: /checkout?price_id=pri_01h... or /checkout?price_ids=pri_01h...,pri_02h...

Webhook verification

# Verify webhook signature manually
:ok = PaddleBilling.Webhook.Verifier.verify(
  raw_body,
  paddle_signature_header,
  signing_secret,
  max_age: 300
)

The webhook controller handles this automatically via the PaddleWebhookSignature plug.

Admin Panel

After running mix paddle_billing.install, the following routes are available:

Route Description
/admin/billing Products list
/admin/billing/products/new Create product
/admin/billing/products/:id/edit Edit product
/admin/billing/products/:id/prices Prices for product
/admin/billing/products/:id/prices/new Create price
/admin/billing/discounts Discounts list
/admin/billing/discounts/new Create discount
/admin/billing/sync Sync dashboard (drift detection, reconciliation)
/checkout Paddle.js checkout (authenticated users)

Admin routes require the :require_admin LiveAuth on_mount hook.

Library Structure

lib/
  paddle_billing.ex              # Public facade with delegates
  paddle_billing/
    config.ex                    # Env-based configuration
    client.ex                    # Req HTTP client + pagination + rate limits
    error.ex                     # Error structs
    resources/
      product.ex                 # Products CRUD
      price.ex                   # Prices CRUD
      discount.ex                # Discounts CRUD
      transaction.ex             # Transactions CRUD + preview
    webhook/
      verifier.ex                # HMAC-SHA256 + replay protection

priv/templates/                  # Installable admin panel templates
  components/
    paddle_components.ex         # Shared function components
  live/paddle/
    products_live.ex             # Products management
    prices_live.ex               # Prices management
    discounts_live.ex            # Discounts management
    sync_live.ex                 # Sync dashboard
    checkout_live.ex             # Paddle.js checkout
  js/
    paddle_checkout.js           # LiveView JS hook for Paddle.js

lib/mix/tasks/
  paddle_billing.install.ex      # Mix task installer

Paddle API v2 Reference