Skip to main content

README.md

# Coffrify Elixir SDK

[![Hex.pm](https://img.shields.io/hexpm/v/coffrify.svg)](https://hex.pm/packages/coffrify)
[![Hex Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/coffrify)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Official Elixir client for [Coffrify](https://coffrify.com) — encrypted
file-transfer infrastructure. This SDK mirrors the JavaScript SDK
(`@coffrify/sdk` v0.9.0) feature-for-feature.

- 35 resources covering the entire Coffrify API surface (transfers,
  webhooks, API keys, audit, analytics, branding, domains, folders,
  collections, members, notifications, GDPR, sessions, downloads,
  alerts, delegated tokens, templates, request inboxes, coffres, magic
  links, quotas, billing, status, changelog, recipients, rooms, MFA,
  marketing, SSO, SCIM, webhook extras, workspace extras).
- Standard Webhooks v2 verification (`webhook-id` /
  `webhook-timestamp` / `webhook-signature`) with key-rotation support.
- Pluggable retry policies (exponential backoff, decorrelated jitter,
  fibonacci backoff, fixed delay) honoring `Retry-After`.
- Local circuit breaker, client-side rate limiter (token / leaky
  bucket), Idempotency-Key auto-generation, crash-safe idempotency
  store (memory or Redis).
- First-class `:telemetry` events and optional OpenTelemetry binding.
- Drop-in `Coffrify.Plug.VerifyWebhook` and
  `Coffrify.Phoenix.WebhookController` mixin.
- Test helpers: payload signing, fixture builders, Bypass-friendly.

## Installation

Add `:coffrify` to your `mix.exs`:

```elixir
def deps do
  [
    {:coffrify, "~> 0.9"}
  ]
end
```

Requires Elixir `~> 1.15` and OTP 26+.

## Quickstart

```elixir
client =
  Coffrify.new(
    api_key: System.fetch_env!("COFFRIFY_API_KEY"),
    timeout_ms: 30_000
  )

# List recent transfers
{:ok, page} = Coffrify.Resources.Transfers.list(client, limit: 20)

# Create a webhook subscription — STORE the returned secret immediately
{:ok, %{"webhook" => wh, "secret" => secret}} =
  Coffrify.Resources.Webhooks.create(client, %{
    name: "Production",
    url: "https://api.example.com/hooks/coffrify",
    events: ["transfer.created", "transfer.downloaded"]
  })

IO.puts("Save this secret in your secret manager: #{secret}")
```

Stream every transfer lazily:

```elixir
client
|> Coffrify.Resources.Transfers.iterate(page_size: 100, status: "active")
|> Stream.take(500)
|> Enum.each(&IO.inspect/1)
```

## Webhook verification (plain Plug)

```elixir
defmodule MyApp.Router do
  use Plug.Router

  plug :match
  plug Plug.Parsers,
    parsers: [:json],
    body_reader: {Coffrify.Plug.VerifyWebhook, :cache_raw_body, []},
    json_decoder: Jason
  plug Coffrify.Plug.VerifyWebhook,
    secret: {System, :fetch_env!, ["COFFRIFY_WEBHOOK_SECRET"]}
  plug :dispatch

  post "/hooks/coffrify" do
    event = conn.assigns.coffrify_event
    MyApp.Webhooks.handle(event)
    send_resp(conn, 200, "ok")
  end
end
```

## Webhook verification (Phoenix)

```elixir
# router.ex
pipeline :coffrify_webhook do
  plug :accepts, ["json"]
  plug Coffrify.Plug.VerifyWebhook,
    secret: {System, :fetch_env!, ["COFFRIFY_WEBHOOK_SECRET"]},
    replay_store: MyApp.CoffrifyReplay
end

scope "/integrations" do
  pipe_through :coffrify_webhook
  post "/coffrify", MyAppWeb.CoffrifyWebhookController, :handle
end

# controller.ex
defmodule MyAppWeb.CoffrifyWebhookController do
  use Coffrify.Phoenix.WebhookController

  @impl Coffrify.Phoenix.WebhookController
  def handle_event(%{"type" => "transfer.created"} = event, _conn) do
    MyApp.Analytics.log(event)
    :ok
  end

  def handle_event(%{"type" => "ping"}, _conn), do: :ok
  def handle_event(_event, _conn), do: :ignore
end
```

The `handle_event/2` callback returns:

- `:ok` / `{:ok, _}` → HTTP 200
- `:ignore` → HTTP 202 (no retry)
- `{:error, _}` → HTTP 500 (Coffrify will retry per its backoff schedule)

## Runtime utilities

### Custom retry policy

```elixir
policy = Coffrify.Runtime.Retry.DecorrelatedJitter.new(
  max_attempts: 5,
  base_delay_ms: 200,
  max_delay_ms: 10_000
)

client = Coffrify.new(api_key: key, retry_policy: policy)
```

### Circuit breaker

```elixir
{:ok, breaker} =
  Coffrify.Runtime.CircuitBreaker.start_link(
    name: MyApp.CoffrifyBreaker,
    failure_threshold: 5,
    open_ms: 30_000
  )

client = Coffrify.new(api_key: key, circuit_breaker: breaker)
```

### Rate limiter

```elixir
{:ok, limiter} =
  Coffrify.Runtime.RateLimit.TokenBucket.start_link(
    name: MyApp.CoffrifyLimiter,
    capacity: 20,
    refill_per_second: 10
  )

client =
  Coffrify.new(
    api_key: key,
    rate_limiter: {Coffrify.Runtime.RateLimit.TokenBucket, limiter}
  )
```

### Idempotency store (crash-safe)

```elixir
{:ok, store} = Coffrify.Runtime.Idempotency.Memory.start_link(name: MyApp.CoffrifyIdem)
client = Coffrify.new(api_key: key, idempotency_store: {Coffrify.Runtime.Idempotency.Memory, store})

# Redis (uses Redix)
{:ok, redis} = Redix.start_link("redis://localhost:6379")
store = Coffrify.Runtime.Idempotency.Redis.new(conn: redis)
client = Coffrify.new(api_key: key, idempotency_store: store)
```

### Webhook replay protection

```elixir
{:ok, replay} = Coffrify.Runtime.WebhookReplay.Memory.start_link(name: MyApp.CoffrifyReplay)
# Pass to Coffrify.Plug.VerifyWebhook via :replay_store
```

## Telemetry

The SDK emits these events:

| Event | Measurements | Metadata |
|---|---|---|
| `[:coffrify, :request, :start]` | `system_time` | `method`, `url`, `attempt` |
| `[:coffrify, :request, :stop]` | `duration` | `method`, `url`, `status`, `attempt`, `result` |
| `[:coffrify, :request, :exception]` | `duration` | `method`, `url`, `kind`, `reason`, `stacktrace` |
| `[:coffrify, :request, :retry]` | `delay_ms` | `method`, `url`, `attempt`, `reason` |
| `[:coffrify, :webhook, :verified]` | `%{}` | `event_type`, `event_id` |
| `[:coffrify, :webhook, :rejected]` | `%{}` | `reason`, `event_type` |

### OpenTelemetry

```elixir
# Requires :opentelemetry_api and :opentelemetry in your deps
Coffrify.Runtime.Telemetry.attach_opentelemetry()
```

## Testing

```elixir
defmodule MyApp.WebhookTest do
  use ExUnit.Case, async: true

  alias Coffrify.Testing
  alias Coffrify.Testing.Fixtures

  test "valid signature" do
    event = Fixtures.webhook_event("transfer.created", %{"transfer" => Fixtures.transfer()})
    body = Jason.encode!(event)
    {body, headers} = Testing.sign_payload_test("whsec_test_secret_123", body)

    assert {:ok, decoded} = Coffrify.Webhook.Verification.verify(
      "whsec_test_secret_123",
      body,
      headers
    )
    assert decoded["type"] == "transfer.created"
  end
end
```

## Resource reference

Every resource module follows the same conventions:

- `list/2`, `get/3` — read endpoints (kw-list of `:query` opts on lists).
- `create/2`, `update/3`, `delete/3` — mutations.
- Functions take a `%Coffrify{}` client as the first argument.
- Return `{:ok, body}` or `{:error, %Coffrify.Error{}}`.

The full module list lives in [`mix.exs`](mix.exs).

## Error handling

```elixir
case Coffrify.Resources.Transfers.get(client, transfer_id) do
  {:ok, transfer} -> transfer
  {:error, %Coffrify.Error.NotFound{}} -> :gone
  {:error, %Coffrify.Error.RateLimited{retry_after_ms: ms}} -> Process.sleep(ms)
  {:error, %Coffrify.Error.Transport{}} -> :network_blip
  {:error, %Coffrify.Error{status: status, message: m}} -> Logger.error(m, status: status)
end
```

## License

[MIT](LICENSE)