Current section
Files
Jump to
Current section
Files
README.md
# Synctera
An unofficial, production-grade Elixir client for the [Synctera](https://synctera.com) 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](#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](#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-Info` fraud-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`](https://github.com/synctera/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](#regenerating-against-an-updated-spec).
## Install
```elixir
def deps do
[
{:synctera, "~> 1.0.0"}
]
end
```
## Quick start
```elixir
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-Key` is 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 every `POST`/`PUT`/`PATCH` unless 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:
```elixir
# 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:
```elixir
client = Synctera.Client.new(api_key: "...", auto_generate_ledger_idempotency_keys: true)
```
## Pagination
```elixir
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
```elixir
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
```elixir
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
```elixir
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
```elixir
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:
```bash
# 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
```bash
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