Dodo Payments for Elixir

An unofficial, explicit, Req-first Elixir SDK for Dodo Payments. It follows Dodo's own resource names, keeps clients as immutable ordinary values with no global API key, and makes payment uncertainty visible instead of hiding it behind automatic retries.

Important

This independent project is not affiliated with, endorsed by, or maintained by Dodo Payments. Use the official Dodo Payments website for the API service and first-party support.

The default transport is Req. The SDK owns a small root supervision tree for isolating short-lived deadline workers; applications do not need to add a child for the default client. It does not create a global API-key or tenant registry. A small DodoPayments.ClientModule boundary allows a company to use Tesla or an internal HTTP stack without changing the public API. Named Finch pools, custom-client connections, and durable jobs remain application-owned.

Version 0.1.0 is a safety-focused public v0. Its API may evolve across 0.x releases, but payment-outcome and credential-handling behavior are treated as hard compatibility boundaries.

Contents

Installation

Add the package to mix.exs:

def deps do
[
{:dodo_payments, "~> 0.1"}
]
end

The SDK requires Elixir 1.15 or later and uses Req 0.7.3 or later in the 0.7 series by default.

The SDK does not read client credentials or API behavior from application configuration and does not automatically load a .env file. Client configuration is explicit: construct a client value from your application's runtime configuration and pass it to calls. The one application-level setting is the optional telemetry execution mode described below. See Setup and configuration for runtime.exs, Phoenix/OTP, named Finch, tests, and telemetry examples.

Five-minute start

Create one client value and pass it to resource functions:

client =
DodoPayments.Client.new!(
api_key: System.fetch_env!("DODO_PAYMENTS_API_KEY"),
environment: :test
)
{:ok, checkout} =
DodoPayments.CheckoutSessions.create(client, %{
product_cart: [
%{product_id: "pdt_123", quantity: 1}
]
})
checkout.checkout_url

:test is the default environment, which reduces accidental live charges. Production use is explicit:

client =
DodoPayments.Client.new!(
api_key: fn -> System.fetch_env!("DODO_PAYMENTS_API_KEY") end,
environment: :live,
timeout: 30_000,
max_attempts: 3
)

The zero-arity key provider supports runtime secret rotation. It is resolved once per logical API call and is never stored by DodoPayments.ReqClient. Direct keys are validated when the client is constructed or updated; a provider's returned value is validated when that provider is resolved.

Phoenix example

The examples/phoenix_checkout directory contains one runnable Phoenix billing lab with ten pattern pages: one-time and subscription checkout, mixed carts, usage and multi-meter billing, prepaid and hybrid credits, on-demand charges, refund/dispute reconciliation, and atomic hard limits. It includes a durable SQLite webhook inbox/outbox, a configurable supervised processor, and network-free integration tests.

See Phoenix checkout example for setup, configuration, workflow APIs, out-of-order webhook handling, and production boundaries.

Public API style

Modules use Dodo's vocabulary rather than an SDK-specific payment ontology:

DodoPayments.Products.list(client, %{page_number: 0, page_size: 25})
DodoPayments.Products.retrieve(client, "pdt_123")
DodoPayments.Subscriptions.retrieve(client, "sub_123")
DodoPayments.UsageEvents.ingest(client, %{events: events})
DodoPayments.Licenses.validate(client, %{license_key: key})
DodoPayments.Invoices.download_payment(client, "pay_123")
DodoPayments.Payouts.download_breakup_csv(client, "payout_123")

Calls normally return {:ok, value} | {:error, exception}. Response objects are typed where a stable schema is useful, while unknown fields remain in extra and known server enum values decode to atoms. A value added by Dodo before an SDK update becomes %DodoPayments.UnknownEnum{} with its exact wire string; server input is never converted into a new atom. Every JSON operation in the 2.47 catalogue has a typed response contract: object responses return SDK structs, paginated responses contain typed items, and bare arrays contain typed items or decoded enum atoms. Empty responses return nil; PDF, CSV, and other binary operations return binaries.

Struct fields carry field-specific types derived from the locked upstream SDK. Every field also permits nil because structs have nullable defaults and the decoder is forward-compatible rather than a required-field validator. Nested objects and lists deliberately remain string-keyed maps for compatibility, so existing access such as payment.customer["customer_id"] continues to work.

JSON number fields in responses retain Jason's integer/float representation; response decoding does not synthesize Decimal values. Outbound Decimal values are supported and encode as exact decimal strings rather than passing through a binary floating-point number. Use the currency's lowest denomination where an endpoint documents integer minor units.

Credit balance and grant amount fields accept both decimal strings declared by the source-locked SDK and JSON numbers currently observed in Dodo's test API. The union is intentional: it preserves real wire behavior without silently coercing precision-bearing strings.

Every operation that accepts a Dodo parameter map also publishes an endpoint-local type, such as create_params on DodoPayments.CheckoutSessions or update_params on DodoPayments.Subscriptions. These types expose the exact top-level atom fields, requiredness, and primitive/list/object shapes from the source-locked upstream declarations. Atom keys, string keys, and mixed maps are all accepted at runtime; the broad compatibility branch in each type exists because Elixir typespecs cannot express a required literal string key. Nested objects stay recursively encodable maps, but every nested field in the locked 2.47 request surface has a named type. This includes discriminated product prices, recursive meter filters, checkout themes and custom fields, entitlement integration unions, refund items, collection groups, discount currency options, and subscription credit/on-demand controls. Source-locked TypeScript string-literal enums become atom unions such as :active | :cancelled; the request codec converts them back to Dodo's exact JSON strings and rejects an unknown atom before dispatch. Existing string values remain accepted during v0.x for compatibility. Metadata is a flat string/number/boolean map, while webhook headers and metadata are string-only as declared upstream. Outbound Date, DateTime, NaiveDateTime, and Decimal values retain their existing ISO/string codec behavior; exact upstream numeric fields remain number() because a Decimal encodes as a JSON string.

For status, headers, and the Dodo request ID:

{:ok, response} =
DodoPayments.Products.retrieve(client, "pdt_123", return: :response)
response.data
response.status
response.request_id
response.headers

Operations that normally accept a parameter map also accept request options directly when no parameters are needed:

{:ok, response} = DodoPayments.Products.list(client, return: :response)

Request-local options are consistent across resource modules:

OptionMeaning
:headersMap or list of header pairs. Values may be scalars or non-empty lists for repeated values. Credentials and transport-owned host, framing, and encoding headers are removed.
:timeoutSet this logical call's fresh total deadline in milliseconds, from 1 through 4_294_967_295.
:max_attemptsOverride the maximum attempt count; replay policy may permit fewer.
:max_response_bytesOverride the bounded response limit or use :infinity.
:return:data (default) or :response for status, headers, and request ID.

Errors are returned as values. ValidationError and ConfigurationError mean no valid request was prepared. APIError is a conclusive application-level Dodo response. Its routine message surfaces HTTP status, request ID, and a string code from Dodo's known top-level or nested error envelope; any server message and unrecognized envelope fields remain available only in the retained body and are not echoed by default. TransportError, TimeoutError, DecodeError, and ResponseTooLarge describe transport or response handling; retry only according to the operation's replay policy. OutcomeUnknown.replay is either :unsafe or :identical_only; follow that value and the accompanying reconciliation guidance before another attempt.

Pagination

Numbered and iterator pagination are deliberately distinct. Dodo's numbered lists are zero-based, and the SDK preserves those page numbers directly. Iterator pages expose Dodo's names without aliases: iterator, prev_iterator, and done?.

{:ok, first} = DodoPayments.Products.list(client, %{page_number: 0})
case DodoPayments.Page.next(first) do
{:ok, second} -> second.items
:done -> []
{:error, error} -> raise error
end

Automatic traversal is opt-in, lazy, cycle-checked, and bounded:

products =
first
|> DodoPayments.Page.stream(max_pages: 20, max_items: 250)
|> Enum.to_list()

Stopping or closing the stream stops requests; it does not prefetch another page. See the pagination module docs for manual page traversal and its bounded page/item closure behavior.

Retries and uncertain payments

The SDK—not Req or a custom adapter—owns retries. An adapter performs exactly one attempt. Reads and explicitly safe operations may retry transient failures. Mutations retry only when Dodo's operation semantics and stable idempotency data make that safe.

If a mutation may have reached Dodo but no conclusive response arrives:

case DodoPayments.Subscriptions.charge(client, "sub_123", params) do
{:ok, payment} ->
{:ok, payment}
{:error, %DodoPayments.Error.OutcomeUnknown{} = error} ->
case error.replay do
:unsafe -> {:reconcile_before_repeating, error}
:identical_only -> {:retry_only_with_identical_idempotency_data, error}
end
{:error, error} ->
{:error, error}
end

See Retries and uncertain outcomes.

Webhooks

Verification implements the Standard Webhooks signing scheme. Pass the exact raw body bytes, before JSON parsing or re-encoding:

case DodoPayments.Webhooks.verify(raw_body, request_headers, webhook_secret) do
{:ok, %DodoPayments.Webhooks.Event{type: :payment_succeeded} = event} ->
Payments.mark_paid(event.data)
{:ok, %DodoPayments.Webhooks.Event{type: %DodoPayments.UnknownEnum{}} = event} ->
# New Dodo event types are not discarded. The complete decoded map and raw
# body remain available as event.payload and event.raw_body.
Events.store_for_later(event)
{:error, %DodoPayments.Webhooks.VerificationError{}} ->
{:error, :unauthorized}
end

Use DodoPayments.Enums.dump!/2 when persisting or forwarding an enum in its wire form—for example, DodoPayments.Enums.dump!(:webhook_event_type, event.type).

Secret rotation does not require downtime:

DodoPayments.Webhooks.verify(raw_body, headers, [new_secret, previous_secret])

The verifier checks timestamp tolerance (five minutes by default) and compares HMACs in constant time. Verification is stateless: Dodo retries and manual replays are expected, and webhook events may arrive out of order. Persist event.webhook_id in a durable inbox with a unique constraint before acknowledging the request. A unique business-resource or order constraint is also needed so two distinct webhook IDs cannot fulfill the same purchase. Timestamps are useful metadata but are not a generic ordering guarantee. Do not acknowledge after merely spawning an unpersisted task, and do not use an ETS or process-local cache as replay or ordering storage. Reconcile current resource state when an out-of-order event could otherwise regress local state.

Untrusted verifier work is bounded by default to a 1 MiB body, 16 KiB of headers, 32 signatures, and 8 active secrets. The corresponding :max_body_bytes, :max_header_bytes, :max_signatures, and :max_secrets options can adjust those ceilings; each encoded secret is additionally limited to 4 KiB by :max_secret_bytes.

Use an existing Req pipeline

Supply a credential-free %Req.Request{}. Plugins, adapters, and Finch options are retained, except for response compression, caller-provided :into collectors, and Req's body-consuming :output option. The SDK protects method, absolute URL, body, authorization, response mode, remaining timeout, retry ownership, redirect behavior, and identity response encoding at execution time.

req =
Req.new(
finch: [name: MyApp.DodoFinch],
user_agent: "my-app/1.0"
)
|> Req.Request.append_request_steps(my_tracing: &MyApp.ReqTracing.attach/1)
client =
DodoPayments.Client.new!(
api_key: System.fetch_env!("DODO_PAYMENTS_API_KEY"),
environment: :live,
req: req
)

Caller-provided Req steps are trusted application code. The SDK rejects a base request that already contains credentials, compressed: true, or an SDK-owned transport header such as host, content-length, transfer-encoding, or accept-encoding. Req skips whole-body decompression when a streaming collector is installed, so the SDK deliberately disables compression instead of advertising support it cannot bound safely. decode_body: false is also forced while the collector is active; the SDK decodes the bounded identity-encoded binary body after collection. Header names are compared case-insensitively. User-agent precedence is reusable Req configuration, then a request-local user-agent header, then the SDK's versioned default. Response steps receive the bounded response body as a normal binary rather than the SDK's streaming accumulator.

If MyApp.DodoFinch is a named Finch, your application must supervise it. The SDK does not start named pools. The setup guide includes a complete child spec and explains where its connection timeout is configured.

Use Tesla or an internal transport

Implement one callback that performs one attempt. Do not copy a plain Tesla.request/2 wrapper for production: Tesla adapters ordinarily return an already-buffered body, which cannot enforce request.max_body_bytes while the response is being read. The transport underneath Tesla must stream into a bounded collector (or reject finite limits before sending):

defmodule MyApp.DodoTeslaClient do
@behaviour DodoPayments.ClientModule
alias DodoPayments.HTTP
@impl true
def request(tesla_client, %HTTP.Request{} = request) do
# Application-owned integration with the chosen Tesla adapter. It must
# perform one attempt and return HTTP.Response or HTTP.TransportError.
MyApp.BoundedTesla.request(tesla_client,
method: request.method,
url: to_string(request.url),
headers: request.headers,
body: request.body,
timeout: request.timeout,
max_body_bytes: request.max_body_bytes
)
end
end
client =
DodoPayments.Client.new!(
api_key: System.fetch_env!("DODO_PAYMENTS_API_KEY"),
environment: :live,
client: {MyApp.DodoTeslaClient, tesla_client}
)

MyApp.BoundedTesla is deliberately application-specific because bounded streaming and cancellation differ by Tesla adapter. See Custom clients for the complete contract and return shapes. The supported Req client already implements bounded streaming.

Operational defaults

DodoPayments.Client.new!(
api_key: "...",
environment: :test,
timeout: 30_000, # total logical call deadline
max_attempts: 3, # attempts, not retries
max_response_bytes: 10_485_760, # reject responses over 10 MiB
retry_base_delay: 200,
retry_max_delay: 2_000
)

Telemetry events are emitted under [:dodo_payments, :request, ...] for logical-call :start/:stop, attempt :start/:stop, and :retry. Attempt metadata includes the attempt number and, when known, status, request ID, or a low-cardinality error category. Metadata never includes API keys, raw transport errors, request bodies, or customer parameters. A :retry event is emitted only after its backoff has completed and the engine has retained enough deadline budget to begin the next attempt. Attempt-start handlers are bounded with a small follow-up reserve so an emitted start is not allowed to consume the entire dispatch budget.

Deadline isolation uses short-lived non-linked supervised Tasks. Before each preparation or adapter Task starts, the SDK snapshots the caller's non-system process dictionary and Logger metadata. This preserves process-local key providers, tracing context, and Req instrumentation while retaining the Task's own $callers, $ancestors, and other runtime-owned dictionary entries. A worker that exceeds the deadline or dies is converted to the SDK's normal timeout/transport outcome instead of terminating the caller.

By default telemetry handlers run within the request's bounded work. Opt into asynchronous telemetry with the application configuration below when handlers must not consume request deadline time:

config :dodo_payments, DodoPayments.Telemetry,
mode: :async,
max_concurrency: 64,
timeout: 1_000

Only :async mode starts the separate bounded telemetry supervisor and its temporary non-linked tasks; the default mode does not leave a telemetry worker subtree running. The mode is selected when the SDK application starts, so changing it at runtime requires restarting the application. Async events are best-effort: events are dropped when capacity is exhausted, have no ordering guarantee, are not retried, and never affect the payment result or request deadline. Telemetry metadata remains low-cardinality and redacted; it does not include payloads, credentials, customer parameters, or raw errors.

That snapshot is compatibility behavior, not a secret store. Avoid placing credentials or large values in the process dictionary; use the client's key provider and normal tracing metadata instead.

For finite max_response_bytes values, the default Req/Finch adapter counts response chunks and cancels the stream as soon as the next chunk would cross the limit. Custom ClientModule implementations receive the same limit and must enforce it while reading; the SDK also rejects an oversized custom-client body defensively if an adapter violates that contract.

Response envelopes and both pagination structs use redacted Inspect implementations. Canonical secrets in typed or untyped response data and sensitive response headers are not printed by routine debugging.

SDK error structs retain bounded raw response or adapter details when those are needed for programmatic reconciliation. Do not serialize whole exceptions into logs, analytics, crash metadata, or user responses: generic serializers do not use Elixir's redacted Inspect implementations. Use the deliberately small summary instead:

Logger.warning("Dodo request failed",
dodo: DodoPayments.Error.safe_summary(error)
)

The response byte limit bounds input and stops an oversized stream early; it is not a heap limit. JSON parsing and schema construction temporarily allocate more memory than the wire body, so choose a lower max_response_bytes for endpoints whose expected payloads are small.

Differences from generated Dodo SDKs

This SDK intentionally defaults to test mode, uses one total logical deadline, and retries only operations whose identical replay is safe. Numbered pages are zero-based. It does not treat 409 as retryable, does not honor a proprietary x-should-retry header, and treats a final 425 as a conclusive refusal. When migrating, review retry and reconciliation behavior instead of copying timeout or page assumptions from another language SDK.

Development

mix deps.get
mix format --check-formatted
mix compile --warnings-as-errors
mix test
MIX_ENV=test mix coveralls
mix credo --strict
mix dialyzer

The test suite uses local ClientModule fakes and Req adapters; it makes no calls to Dodo Payments.

Security

Report suspected SDK vulnerabilities privately to security@developing.tools. Do not open a public issue for an undisclosed vulnerability or include live API keys, webhook secrets, customer data, or unredacted request and response bodies in a report. Vulnerabilities in Dodo Payments' hosted API or service should be reported directly to Dodo Payments. See the repository's complete security policy for scope, contacts, and the current 0.x support policy.

License

Released under the MIT License.