A production-grade Go 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.
Zero external dependencies. go.mod declares none — the entire
module is built on the standard library (net/http, encoding/json,
encoding/xml, crypto/hmac, context, generics). Nothing to vendor,
nothing to audit upstream.
tipalti-go/
tipalti.go # the ONLY .go file at the module root:
# public Client facade + re-exports
internal/
domain/ # entities, value objects, Port interfaces
shared/ # — no HTTP/XML/JSON knowledge at all
payee/ invoice/ payment/
soap/ purchaseorder/ employee/
application/ # use-case services orchestrating a
payeeapp/ invoiceapp/ # domain Port (e.g. adding pagination
paymentapp/ # streaming) — still transport-agnostic
soappayeeapp/ soappayerapp/
procurementapp/
infrastructure/ # adapters implementing each domain Port
httptransport/ # against Tipalti's actual wire format
oauth2/
restadapter/
soapadapter/
procurementadapter/
config/ apperrors/ pagination/
webhook/ ratelimit/ telemetry/
The dependency direction is strictly one-way and acyclic:
domain ← application ← infrastructure ← root. Domain packages import
nothing from this module except other domain packages; verified by
grep in CI (see below) as well as go vet.
Since Go's internal/ convention makes those packages unimportable
outside this module, tipalti.go re-exports every public type via type
aliases (type Resource = shared.Resource, etc.) so external callers get
a clean, flat API without ever touching the internal layout.
go get github.com/iamkanishka/tipalti-go
client, err := tipalti.New(
tipalti.WithMode(tipalti.ModeSandbox),
tipalti.WithREST("client-id", "client-secret"),
tipalti.WithSOAP("payer-name", "api-key"),
tipalti.WithProcurement("procurement-api-key"),
)
if err != nil {
log.Fatal(err)
}
// Modern REST API
page, err := client.Payees.List(ctx, tipalti.PayeeListParams{})
stream := client.PayeeStream(ctx, tipalti.PayeeListParams{})
for payee := range stream.Items() {
fmt.Println(payee.ID(), payee.String("name"))
}
if err := stream.Err(); err != nil {
log.Fatal(err)
}
// Legacy SOAP API
result, err := client.SOAP.Payer.ProcessPayments(ctx, []map[string]any{
{"idap": "vendor-123", "amount": 100.00, "currency": "USD", "refCode": "pay-1"},
}, tipalti.ProcessPaymentsOptions{PaymentGroupTitle: "Weekly payout"})
// Procurement REST API
pos, err := client.Procurement.PurchaseOrders.List(ctx, tipalti.PurchaseOrderListParams{})Only populate the credential options for the API families you actually
use — a Client built with just WithSOAP is fine as long as you only
call client.SOAP.* methods.
Every error is one of six typed errors; use errors.As:
_, err := client.Payees.Get(ctx, "p_123")
var authErr *tipalti.AuthenticationError
var rlErr *tipalti.RateLimitError
var valErr *tipalti.ValidationError
var faultErr *tipalti.SOAPFaultError
switch {
case errors.As(err, &authErr):
// bad/expired credentials
case errors.As(err, &rlErr):
// rate limited; rlErr.RetryAfter has the hint, if any
case errors.As(err, &valErr):
// malformed request; valErr.Errors has field-level details
case errors.As(err, &faultErr):
// SOAP <soap:Fault>; faultErr.FaultCode / FaultString
}Every REST list use case has a matching client.<X>Stream method
returning a channel-based Stream[Resource] (channel-based rather than
iter.Seq since this module targets Go 1.22):
stream := client.InvoiceStream(ctx, tipalti.InvoiceListParams{Status: "pending"})
items, err := tipalti.Collect(stream) // or range over stream.Items()HMAC-SHA256 request signing is handled automatically — every
client.SOAP.Payee/client.SOAP.Payer method knows its operation's EAT
(Encryption Additional Terms) parameter and folds it into the signed
request for you. All 45 legacy operations (21 Payee + 24 Payer) are
covered.
csv, _ := os.ReadFile("employees.csv")
_, err := client.Procurement.Employees.ImportEmployees(ctx, csv)func handleTipaltiWebhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
event, err := tipalti.ParseWebhook(string(body))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
handleEvent(event.EventType(), event)
}client, _ := tipalti.New(
tipalti.WithREST(id, secret),
tipalti.WithTelemetryHook(tipalti.TelemetryFunc(func(e tipalti.TelemetryEvent) {
log.Printf("%s.%s -> status=%d err=%v in %s", e.API, e.Operation, e.Status, e.Err, e.Duration)
})),
)limiter := tipalti.NewRateLimiter(5, time.Minute) // Procurement API's documented PO-update limit
if err := limiter.Wait(ctx); err != nil {
return err
}
client.Procurement.PurchaseOrders.Update(ctx, attrs)- Concurrency: the OAuth2
TokenManageris safe for concurrent use — concurrent callers during a token refresh share the single in-flight request rather than each firing their own. Verified withgo test -race. - Generics:
Stream[T](pagination) and a couple of small internal helpers use generics; no code generation. - REST endpoint shapes (Payees/Invoices/Payments) follow Tipalti's
documented conventions for the modern REST API — see the doc comment on
internal/infrastructure/restadapter/client.goif your instance's exact response envelope differs; the request/auth/error-handling machinery there is meant to be reused as-is.
gofmt -l . # clean
go vet ./... # clean
go build ./... # clean
go test ./... -race -count=1 # all passing
golangci-lint run ./... # 0 issues
.golangci.yml enables a production-grade linter set well beyond the
tool's 6-linter default — including gosec, errorlint, gocyclo,
bodyclose, contextcheck, exhaustive, prealloc, unparam,
revive, and more (see the file for the full list). All clean, verified
in a fresh checkout.
golangci-lint itself is installed from a GitHub release binary rather
than go install, and dependencies are fetched via GOPROXY=direct —
see comments in this repo's build notes if proxy.golang.org isn't
reachable in your environment either; none of that affects consuming
this module, since it has zero dependencies of its own.
MIT