Synctera

An unofficial, production-grade Elixir client for the Synctera Banking-as-a-Service API.

⚠️ Important: spec currency

This client is generated from the most recent machine-readable OpenAPI spec available to the generator — the snapshot Synctera checks into their own client-libraries-go scaffold repo. That snapshot covers 114 paths / 174 operations across every core domain (customers, persons, businesses, accounts, cards, ACH, wires, internal transfers, transactions, documents, webhooks, KYC/KYB verification, watchlist monitoring, external accounts/cards, payment schedules, remote check deposit, reconciliations, applications, and sandbox simulations).

It does not yet include a handful of newer resources visible in Synctera's live docs but absent from that spec snapshot: Spend Controls, Evaluation Overrides, Merchants, Institutions, Enhanced Due Diligence (EDD), Customer Risk Rating (CRR), and Incoming Wires as a standalone resource.mix generate against an updated spec closes that gap without a rewrite — see Regenerating against an updated spec.

Install

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

Quick start

client = Synctera.Client.new(api_key: System.fetch_env!("SYNCTERA_API_KEY"), environment: :sandbox)
{:ok, person} =
Synctera.Persons.create_person(client, body: %{"first_name" => "Ada", "last_name" => "Lovelace"})
# Or the bang variant, which raises Synctera.Error on failure:
person = Synctera.Persons.create_person!(client, body: %{"first_name" => "Ada", "last_name" => "Lovelace"})

Every function's first argument is the client (idiomatic Elixir, cf. Ecto.Repo); required path parameters come next positionally; everything else — query params, :body, :idempotency_key, :device_info — is a single trailing keyword list.

Idempotency

Synctera has two distinct idempotency behaviors, and this client does not paper over the difference:

# Raises Synctera.UsageError — you must supply a key explicitly for ledger calls:
Synctera.ACH.add_transaction_out!(client, body: outgoing_ach)
# Correct: mint one key per logical operation, reuse it only when retrying that same operation.
key = Synctera.Idempotency.generate_key()
Synctera.ACH.add_transaction_out(client, body: outgoing_ach, idempotency_key: key)

If you understand the tradeoff and want auto-generation on ledger calls anyway:

client = Synctera.Client.new(api_key: "...", auto_generate_ledger_idempotency_keys: true)

Pagination

Synctera.Pagination.stream(fn page_token ->
with {:ok, page} <- Synctera.Persons.list_persons(client, page_token: page_token) do
{:ok, page["persons"], page["next_page_token"]}
end
end)
|> Stream.each(&IO.inspect/1)
|> Stream.run()
# Or collect everything into a list:
all_persons =
Synctera.Pagination.collect_all(fn page_token ->
with {:ok, page} <- Synctera.Persons.list_persons(client, page_token: page_token) do
{:ok, page["persons"], page["next_page_token"]}
end
end)

The stream is lazy — pages are only fetched as items are consumed.

Webhook verification

def handle_webhook(conn, _params) do
{:ok, raw_body, conn} = Plug.Conn.read_body(conn)
case Synctera.Webhook.verify(
payload: raw_body,
signature_header: List.first(Plug.Conn.get_req_header(conn, "synctera-signature")) || "",
timestamp_header: List.first(Plug.Conn.get_req_header(conn, "synctera-timestamp")) || "",
secret: System.fetch_env!("SYNCTERA_WEBHOOK_SECRET")
# previous_secret: System.get_env("SYNCTERA_WEBHOOK_SECRET_PREVIOUS"), # during rotation
) do
:ok ->
event = Synctera.JSON.decode!(raw_body)
# ... handle event
send_resp(conn, 200, "")
{:error, %Synctera.WebhookSignatureError{}} ->
send_resp(conn, 400, "invalid signature")
end
end

Device fingerprinting

Synctera.Accounts.get_account(client, account_id,
device_info: %Synctera.DeviceInfo{customer_id: person["id"], ip_address: remote_ip}
)

Or set it once for a whole server-side context via default_device_info: on the client — per-call :device_info overrides it.

Error handling

case Synctera.Persons.get_person(client, id) do
{:ok, person} ->
person
{:error, error} ->
cond do
Synctera.Error.not_found?(error) -> :not_found
Synctera.Error.validation_error?(error) -> {:invalid, error.code, error.body}
Synctera.Error.conflict?(error) -> :conflict
Synctera.Error.rate_limited?(error) -> :rate_limited
true -> {:error, error}
end
end

Every %Synctera.Error{} carries status, code (Synctera's machine-readable error code, when present), body, request_id, url, and method.

Configuration

Synctera.Client.new(
api_key: "...",
environment: :sandbox, # :sandbox | :t_minus_10 | :production
base_url: nil, # override entirely, e.g. for a mock server in tests
receive_timeout_ms: 30_000,
retry: [max_retries: 2, base_delay_ms: 250, max_delay_ms: 8_000],
auto_generate_ledger_idempotency_keys: false,
default_device_info: %Synctera.DeviceInfo{customer_id: "...", ip_address: "..."},
default_headers: [],
on_response: fn %{method: _, url: _, status: _, request_id: _, duration_ms: _} ->
# hook for logging/metrics
end
)

Regenerating against an updated spec

The entire resource-module surface (lib/synctera/resources/*.ex) is produced by scripts/generate.py from an OpenAPI document. To pick up new Synctera resources or fields:

# Drop an updated spec at reference/openapi.json, then:
mix generate # regenerates lib/synctera/resources/*.ex and runs mix format
mix verify # generate + format check + compile --warnings-as-errors + test

Design notes

Zero dependencies. This package depends on nothing beyond Erlang/OTP: HTTP via :httpc (part of :inets), TLS via :ssl with explicit peer verification (:public_key.cacerts_get/0), HMAC via :crypto, and a small hand-written JSON codec (Synctera.JSON) rather than requiring Jason or another library. This means adding synctera to your project never forces a JSON or HTTP client choice on you, and never risks a dependency conflict. If you'd prefer to delegate to Jason for performance, Synctera.JSON's two functions (encode/1, decode/1) are a small, swappable surface — a future version may make this pluggable via Application config.

Plain maps, not per-schema structs. Responses decode into plain string-keyed maps (%{"first_name" => "Ada"}), not 457 generated struct modules. Elixir has no compile-time structural typing to enforce against a dynamic HTTP response the way TypeScript or Go do, so generating that many structs would add a large, harder-to-navigate surface without buying real safety — every field would still need a runtime presence check either way. @spec and @doc on every generated function still document the shape of opts and link back to the endpoint's behavior.

Development

mix generate # spec -> resource modules
mix format
mix compile --warnings-as-errors
mix test
mix verify # everything, in order — what CI should run

License

MIT