oarlock

Paddle Billing for Elixir, with a deliberately small surface.

oarlock gives you typed Paddle.* structs, explicit %Paddle.Client{} passing, and pure-function webhook verification/parsing. It does not try to be your billing domain model, your Phoenix integration layer, or your persistence strategy.

If you are integrating this into a SaaS app, start with Getting Started. It explains the actual user flow: customer -> address -> transaction -> checkout -> webhook -> subscription.

What You Can Do Today

Proof Boundary

Use the same proof ladder throughout an integration:

What This Library Is For

The mental model is simple:

That seam is intentionally narrow. It helps you talk to Paddle in idiomatic Elixir without pulling Phoenix, Ecto, Plug, or Bandit into the core library.

Installation

The Hex package is named oarlock, while the OTP app and module namespace are still :paddle / Paddle.*.

def deps do
  [
    {:paddle, "~> 0.1.1", hex: :oarlock}
  ]
end

The package runtime depends on Req ~> 0.7.4. The supported BEAM range is Elixir ~> 1.19; the tracked and fully exercised toolchain is Elixir 1.19.5 / OTP 28.1. Broader OTP combinations are not implied by the local compatibility evidence.

Client Construction Contract

Paddle.Client.new!/1 accepts only :api_key, :environment, and :base_url. The key must be a nonblank binary, URLs must be absolute HTTP(S) URLs with a host, and invalid input raises a value-redacted ArgumentError before Req construction or network dispatch.

Input Result
No environment and no base URL Canonical :sandbox client
:sandbox, with no URL or the canonical sandbox URL Canonical :sandbox client
:live, with no URL or the canonical live URL Canonical :live client
:custom plus an absolute HTTP(S) base URL Explicit :custom client
Noncanonical base URL only Inferred :custom client
Unknown/duplicate options, invalid key/URL, custom without URL, or conflicting canonical environment/URL Secret-safe ArgumentError before Req construction

Retry, Mutation, and Inspection Safety

Safe GET and HEAD reads retry only HTTP 408, 429, 500, 502, 503, and 504 or the documented transient transport reasons :timeout, :econnrefused, and :closed. The ceiling is three retries (four total attempts). A read-side 429 honors Retry-After only up to 60,000 ms; retry: false disables retries for that call.

Every POST, PATCH, PUT, and DELETE mutation makes a single attempt. retry: true is rejected before dispatch, and idempotency_key is unsupported: the presence of a header would not prove provider deduplication. A mutation transport failure or terminal HTTP 408/5xx response is returned as a non-retryable, ambiguous %Paddle.Error{}. Reconcile with the fixed :lookup, :webhook, and :provider_dashboard actions; never blindly replay the mutation.

Inspection of capability-bearing public values retains their stored data but renders credentials, operational URLs, transport state, and secret-capable raw_data containers with the stable [REDACTED] marker.

Quick Start

Create a client:

client =
  Paddle.Client.new!(
    api_key: System.fetch_env!("PADDLE_API_KEY"),
    environment: :sandbox
  )

Create the customer and address you want Paddle to bill:

{:ok, customer} =
  Paddle.Customers.create(client,
    email: "ada@example.com",
    name: "Ada Lovelace",
    locale: "en"
  )

{:ok, address} =
  Paddle.Customers.Addresses.create(client, customer.id,
    description: "Home office",
    first_line: "123 Main Street",
    city: "New York",
    postal_code: "10001",
    region: "NY",
    country_code: "US"
  )

Create a transaction for a recurring price, then redirect the user to the hosted checkout URL:

case Paddle.Transactions.create(client,
       customer_id: customer.id,
       address_id: address.id,
       items: [%{price_id: "pri_monthly_123", quantity: 1}]
     ) do
  {:ok, %Paddle.Transaction{} = transaction} ->
    checkout_url = transaction.checkout.url
    # Redirect user to checkout_url
  {:error, %Paddle.Error{} = error} ->
    raise "Failed: #{error.message}"
end

Later, when Paddle calls your webhook endpoint, verify the raw body before you trust it:

with {:ok, :verified} <-
       Paddle.Webhooks.verify_signature(raw_body, signature_header, secret),
     {:ok, event} <- Paddle.Webhooks.parse_event(raw_body) do
  {:ok, event}
end

For a recurring purchase, the usual app-level next step is:

  1. Confirm the transaction completed.
  2. Save the Paddle customer and subscription IDs against your user or account.
  3. Grant access in your app.

When you need more than one page of provider state, use the pagination helpers:

active_subscriptions =
  client
  |> Paddle.Subscriptions.stream(status: "active")
  |> Enum.take(100)

{:ok, subscriptions} = Paddle.Subscriptions.all(client, status: "active")
{:ok, page} = Paddle.Subscriptions.list(client, status: "active")
next_cursor = Paddle.Page.next_cursor(page)

Guides

Current Boundary

oarlock is intentionally not:

Paddle.MockServer is the one intentionally optional development/test fixture in the package. It needs plug and bandit to serve local HTTP requests, but the core SDK modules, webhook verification, typed resources, and Req-based HTTP client do not require Phoenix, Ecto, Plug, or Bandit.

If you need the exact supported public surface, use the Accrue Seam Contract as the source of truth.