Synctera
An unofficial, production-grade Elixir client for the Synctera Banking-as-a-Service API.
- Zero dependencies — built entirely on Erlang/OTP's standard library (
:httpc,:crypto,:ssl), plus a small hand-written JSON codec. Imposes no HTTP client or JSON library choice on your application. See Design notes for why. - 174 generated functions (348 including bang variants) across 27 resource modules, generated directly from Synctera's OpenAPI spec — never hand-guessed.
- Idiomatic Elixir: client-first function arguments (
Synctera.Persons.get_person(client, id)),{:ok, _} | {:error, _}tuples with!bang variants that raise, keyword-list options,Stream-based pagination. - Correct idempotency semantics — Synctera has two genuinely different idempotency behaviors depending on the endpoint; this client models both instead of pretending they're the same (see Idempotency).
- Automatic retries with exponential backoff + jitter, honoring
Retry-After, limited to the status codes (429, 5xx) Synctera's docs confirm are safe to retry. - Webhook signature verification — HMAC-SHA256, secret-rotation support, constant-time comparison, replay-window protection.
Customer-Device-Infofraud-fingerprinting header built from a typed struct.
⚠️ 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:
Standard endpoints (most of the API): an
Idempotency-Keyis optional. A repeated request with the same key returns the original cached response for 7 days. This client auto-generates a key for you on everyPOST/PUT/PATCHunless you supply one — always safe to retry.Ledger / money-movement endpoints (ACH, wires, internal transfers, RDC deposits): the key is required by Synctera, and a repeat with the same key is never served from cache — it's rejected with 409 or 422. Because a silently-retried request would fail loudly here instead of replaying safely, this client will not auto-generate a key for these calls by default:
# 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