Skip to main content

README.md

# Triple

[![Hex.pm](https://img.shields.io/hexpm/v/triple.svg)](https://hex.pm/packages/triple)
[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/triple)
[![License](https://img.shields.io/hexpm/l/triple.svg)](https://github.com/iamkanishka/triple/blob/main/LICENSE)

An Elixir client for the [Triple](https://jointriple.com) transaction data
enrichment API: turn raw bank/card transaction strings into clean merchant
names, logos, categories, locations, contact details, subscription
detection, CO₂ estimates, fraud signals, and payment processor
identification.

## Installation

Add `:triple` to your `mix.exs` dependencies:

```elixir
def deps do
  [
    {:triple, "~> 1.0.0"}
  ]
end
```

## Quick start

```elixir
client = Triple.new(api_key: System.fetch_env!("TRIPLE_API_KEY"))

{:ok, enriched} =
  Triple.enrich_transaction(client, %{
    merchant_name: "AMZN MKTP UK",
    transaction_type: :CARD_TRANSACTION,
    transaction_id: Triple.Util.generate_transaction_id(),
    transaction_amount: 24.99,
    transaction_currency: "GBP",
    channel_type: :ECOMMERCE
  })

enriched.visual_enrichments.merchant_clean_name
#=> "Amazon"
```

`client` is a plain `%Triple.Config{}` struct, not a process — pass it
explicitly to every call. That keeps the library safe to use with multiple
Triple accounts/environments (e.g. sandbox alongside production) in the
same application, with no global or process-dictionary state to worry
about.

API keys starting with `tr_test_` are sandbox keys, `tr_live_` are
production keys — the environment (and therefore which host gets called)
is inferred automatically from whichever you pass in.

## Configuration

Every option can be passed to `Triple.new/1` directly:

```elixir
Triple.new(
  api_key: "tr_live_xxx",
  receive_timeout: 15_000,
  max_retries: 5
)
```

...or set application-wide, and used as a fallback whenever it isn't
passed explicitly:

```elixir
# config/runtime.exs
config :triple, api_key: System.fetch_env!("TRIPLE_API_KEY")
```

See `Triple.Config` for the full list (timeouts, retry strategy, a custom
`Req` options passthrough, an optional client-side rate limiter, etc).

## Enrichment

Two flavours, matching Triple's two enrichment endpoints:

```elixir
# Structured — when you have discrete fields
Triple.enrich_transaction(client, %{
  merchant_name: "AMZN MKTP UK",
  transaction_type: :CARD_TRANSACTION,
  transaction_id: Triple.Util.generate_transaction_id(),
  merchant_country: "GBR",
  transaction_amount: 24.99,
  transaction_currency: "GBP"
})

# Unstructured — when all you have is a raw description string
Triple.enrich_unstructured_transaction(client, %{
  transaction_id: Triple.Util.generate_transaction_id(),
  text: "CRD PUR 4321 NETFLIX.COM 866-5797172 CA",
  transaction_amount: 15.49,
  transaction_currency: "USD"
})
```

Every input is validated locally before any network call is made —
invalid input returns `{:error, %Triple.Error{type: :validation}}`
immediately, with the same field-level error shape Triple's own API would
return.

The two response shapes differ slightly, matching Triple's own OpenAPI
spec: the structured (`v1`) response wraps every enrichment feature
(location, subscriptions, CO₂, fraud, contact, payment processor) in an
`enabled?`-flagged struct, since not every transaction carries every kind
of signal — an online purchase, for instance, never has a
`merchant_location`. The unstructured (`v2`) response uses flat, simply
nullable structs instead. See `Triple.Types.Enrich.V1.Response` and
`Triple.Types.Enrich.V2.Response` for the exact shapes, including a couple
of small helpers like `Triple.Types.Enrich.V1.Response.Subscriptions.recurring?/1`.

## Brands, feedback, stocks, cryptos, and TLS

```elixir
# Look up a brand directly (e.g. to refresh a cached logo)
Triple.fetch_brand(client, "497f6eca-6276-4993-bfeb-53cbbbba6f08")

# Tell Triple when enrichment data is wrong or missing
Triple.report_feedback(client, %{
  transaction_id: "txn_123",
  report: :brand_name,
  response_value: "AMZN MKTP UK",
  feedback: "Should be Amazon"
})

# Brokerage data
Triple.fetch_stock(client, "LU1778762911", format: :svg_light)
Triple.fetch_crypto(client, "bitcoin")

# Issue an mTLS client certificate (hits Triple's control-plane host)
Triple.issue_tls_certificate(client, %{public_key: pem, lifetime: 365})
```

Every function above also has a `!` counterpart (`fetch_brand!/2`,
`report_feedback!/2`, ...) that raises `Triple.Error` instead of returning
`{:error, _}`.

## Error handling

Every call returns `{:ok, result} | {:error, %Triple.Error{}}`:

```elixir
case Triple.enrich_transaction(client, attrs) do
  {:ok, enriched} ->
    enriched

  {:error, %Triple.Error{type: :validation, errors: errors}} ->
    # local validation failure — `errors` is a `field => [messages]` map
    Logger.warning("Bad enrich payload: #{inspect(errors)}")

  {:error, %Triple.Error{type: :rate_limited, retry_after: seconds}} ->
    # only seen after the client's own retries are exhausted
    Logger.warning("Triple rate limit hit, retry after #{seconds}s")

  {:error, error} ->
    Logger.error(Exception.message(error))
end
```

`Triple.Error` distinguishes `:validation`, `:unauthenticated`,
`:forbidden`, `:not_found`, `:rate_limited`, `:server_error`,
`:unexpected_status`, and `:network_error` — see the module docs for the
full field list.

## Retries

`408`, `429`, `500`, `502`, `503`, and `504` responses (and transport
errors) are retried automatically with exponential backoff, honoring
Triple's `retry-after` header on `429`s. Configure or disable this via
`Triple.Config`:

```elixir
Triple.new(api_key: key, max_retries: 5)
Triple.new(api_key: key, retry: false)
```

## Telemetry

`[:triple, :request, :start | :stop | :exception]` events are emitted
around every call — see `Triple.Telemetry` for the full event/metadata
reference, handy for logging, metrics, or tracing.

## Optional client-side rate limiting

For bulk workloads (e.g. backfilling historical transactions) where you'd
rather avoid `429`s in the first place:

```elixir
{:ok, _pid} = Triple.RateLimiter.start_link(name: MyApp.TripleLimiter, rate: 50, per: :second)
client = Triple.new(api_key: key, rate_limiter: MyApp.TripleLimiter)
```

See `Triple.RateLimiter` for details and its limits (single-node only).

## Testing code that calls Triple

This library is built on [`Req`](https://hexdocs.pm/req), so you can stub
responses in your own tests via `Req`'s `:adapter` option — no extra test
dependency required:

```elixir
adapter = fn req ->
  {req, %Req.Response{status: 200, body: %{"transaction_id" => "txn_1"}}}
end

client = Triple.new(api_key: "tr_test_xxx", req_options: [adapter: adapter])
```

The `adapter` function receives the fully-built `%Req.Request{}` (so you
can assert on `req.method`, `req.url`, `req.options[:json]`, etc) and must
return `{req, %Req.Response{}}` or `{req, exception}`.

## Sandbox vs. production

Triple provides fully isolated sandbox and production environments (API
hosts, dashboards, and databases). Pass a `tr_test_*` key to hit sandbox,
or `tr_live_*` for production — `Triple.Config` infers and warns on any
mismatch if you also pass `environment:` explicitly.

## License

MIT. See [LICENSE](LICENSE).

## Disclaimer

This is a community-maintained client and is not officially affiliated
with or endorsed by Triple Technologies. See
[jointriple.com](https://jointriple.com) for the official product and
[docs.triple.app](https://docs.triple.app) for the official API
reference.