A production-grade Elixir client for the full Tipalti API surface: the modern OAuth2 REST API, the legacy HMAC-signed SOAP Payee/Payer API, and the Procurement REST API.

Built deliberately with a minimal dependency footprint — the HTTP transport runs on OTP's bundled :httpc/:ssl/:xmerl instead of pulling in a full HTTP client stack, so this package brings in only two small, dependency-free libraries (jason, telemetry).

Why three APIs?

Tipalti's product grew over time, and it shows in the API surface:

APIStyleAuthCovered by
Modern RESTJSON over HTTPSOAuth 2.0 client credentialsTipalti.Payees, Tipalti.Invoices, Tipalti.Payments
Legacy SOAPXML over HTTPSHMAC-SHA256 signed requestsTipalti.SOAP.Payee, Tipalti.SOAP.Payer
Procurement RESTJSON over HTTPSStatic x-api-keyTipalti.Procurement.PurchaseOrders, Tipalti.Procurement.Employees

This package speaks all three through one consistent interface, sharing a single HTTP transport, retry policy, and telemetry layer underneath.

Installation

def deps do
  [
    {:tipalti, "~> 1.0.0"}
  ]
end

Quick start

config = Tipalti.Config.new(
  mode: :sandbox,
  rest: [client_id: "...", client_secret: "..."],
  soap: [payer_name: "...", api_key: "..."],
  procurement: [api_key: "..."]
)

# Modern REST API
{:ok, page} = Tipalti.Payees.list(config)
for payee <- Tipalti.Payees.stream(config) do
  IO.inspect(payee)
end

# Legacy SOAP API
{:ok, result} =
  Tipalti.SOAP.Payer.process_payments(config, [
    %{idap: "vendor-123", amount: 100.00, currency: "USD", refCode: "pay-1"}
  ])

# Procurement REST API
{:ok, pos} = Tipalti.Procurement.PurchaseOrders.list(config)

Only populate the Tipalti.Config sections you actually use — a config built with just soap: opts is fine as long as you only call Tipalti.SOAP.* functions with it.

Error handling

Every function has a non-bang variant returning {:ok, result} / {:error, exception}, and a ! variant that raises instead:

case Tipalti.Payees.get(config, "p_123") do
  {:ok, payee} -> payee
  {:error, %Tipalti.AuthenticationError{}} -> :bad_credentials
  {:error, %Tipalti.RateLimitError{retry_after: ms}} -> :retry_later
  {:error, %Tipalti.ValidationError{errors: errors}} -> :bad_input
  {:error, %Tipalti.SOAPFaultError{fault_string: msg}} -> :soap_fault
  {:error, error} -> {:other_error, error}
end

payee = Tipalti.Payees.get!(config, "p_123")

See Tipalti.Error for the full exception hierarchy.

Pagination

Every REST list endpoint has a stream/2 variant that lazily walks every page:

Tipalti.Invoices.stream(config, status: "pending")
|> Stream.filter(&(&1.amount > 1000))
|> Enum.to_list()

SOAP signing

The legacy API's HMAC-SHA256 request signing (Tipalti.SOAP.Signature) is handled automatically — every Tipalti.SOAP.Payee/Tipalti.SOAP.Payer function knows its operation's EAT (Encryption Additional Terms) parameter and folds it into the signature for you.

Procurement employee import

The three-step CSV upload flow (get a signed URL, upload the CSV, trigger the import) is wrapped in one call:

csv = File.read!("employees.csv")
{:ok, _result} = Tipalti.Procurement.Employees.import_employees(config, csv)

IPN webhooks

def handle_tipalti_webhook(conn) do
  {:ok, body, conn} = Plug.Conn.read_body(conn)

  case Tipalti.Webhook.parse(body) do
    {:ok, event} ->
      handle_event(Tipalti.Webhook.event_type(event), event)
      Plug.Conn.send_resp(conn, 200, "")

    {:error, _reason} ->
      Plug.Conn.send_resp(conn, 400, "")
  end
end

Telemetry

Every request emits a :telemetry.span/3 under [:tipalti, :request] by default (configurable via Tipalti.Config :telemetry_prefix). See Tipalti.Telemetry for the full event/metadata reference.

Rate limiting

The Procurement API documents explicit rate limits. Tipalti.HTTP already retries a 429 with backoff, but for high-throughput integrations Tipalti.RateLimiter (an optional token-bucket limiter) lets you avoid tripping the limit client-side in the first place.

Design notes

  • HTTP transport: Tipalti.HTTP is built on OTP's :httpc, with exponential backoff + full jitter on retries, TLS verification via :public_key.cacerts_get/0 (OTP 25+), and connection pooling disabled (each request opens its own connection — simpler and more predictable across the range of environments this library runs in, e.g. serverless).
  • OAuth2 tokens: Tipalti.Auth.TokenServer is a GenServer, one per distinct client_id, started on demand and supervised under Tipalti.Application. Tokens are cached and refreshed 60 seconds before expiry.
  • REST endpoint shapes (Tipalti.Payees/Invoices/Payments) follow Tipalti's documented conventions for the modern REST API — see the moduledoc caveat in Tipalti.Payees if your instance's exact response envelope differs; the request/auth/pagination/error-handling machinery underneath is meant to be reused as-is.

Quality

mix format --check-formatted   # clean
mix compile --warnings-as-errors  # clean
mix test                       # 43 tests, 0 failures
mix credo --strict             # 0 issues (312 functions)
mix dialyzer                   # 0 errors

credo and dialyxir are dev/test-only dependencies, pinned to git tags alongside their own small transitive deps (bunt, file_system, erlex) for environments where the hex.pm registry isn't reachable — see mix.exs. The Dialyzer PLT takes a few minutes to build the first time (mix dialyzer --plt); subsequent runs are fast.

Testing this package

The test suite uses a small :gen_tcp-based mock HTTP server (test/support/mock_server.ex) instead of Bypass/Plug, keeping the dependency list minimal even for development/test.

mix test

License

MIT