Tipalti

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

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