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:
| API | Style | Auth | Covered by |
|---|---|---|---|
| Modern REST | JSON over HTTPS | OAuth 2.0 client credentials | Tipalti.Payees, Tipalti.Invoices, Tipalti.Payments |
| Legacy SOAP | XML over HTTPS | HMAC-SHA256 signed requests | Tipalti.SOAP.Payee, Tipalti.SOAP.Payer |
| Procurement REST | JSON over HTTPS | Static x-api-key | Tipalti.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.HTTPis 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.TokenServeris a GenServer, one per distinctclient_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 inTipalti.Payeesif 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