BETA
Skip to content

Elixir

No official SDK yet

Krafter Mail does not ship an official Elixir SDK. The HTTP API is small and stable, so the recommended approach today is to call it directly with Req. This page shows working snippets that mirror the Mail API reference.

Setup

Add Req to your mix.exs:

elixir
def deps do
  [
    {:req, "~> 0.5"}
  ]
end

Read the API key from runtime config:

elixir
# config/runtime.exs
config :my_app, :krafter,
  api_key: System.fetch_env!("KRAFTER_API_KEY"),
  base_url: "https://app.krafter.dev/api/v1"

A minimal client

elixir
defmodule MyApp.KrafterMail do
  @moduledoc "Thin HTTP wrapper around the Krafter Mail API."

  def request(method, path, opts \\ []) do
    body = Keyword.get(opts, :json)
    idempotency_key = Keyword.get(opts, :idempotency_key)

    headers = [
      {"authorization", "Bearer " <> config(:api_key)},
      {"content-type", "application/json"}
      | if(idempotency_key, do: [{"idempotency-key", idempotency_key}], else: [])
    ]

    Req.request(
      method: method,
      url: config(:base_url) <> path,
      headers: headers,
      json: body
    )
    |> handle_response()
  end

  defp handle_response({:ok, %Req.Response{status: status, body: body, headers: headers}})
       when status in 200..299,
       do: {:ok, body, headers}

  defp handle_response({:ok, %Req.Response{status: status, body: body, headers: headers}}),
    do: {:error, %{status: status, body: body, headers: headers}}

  defp handle_response({:error, exception}), do: {:error, exception}

  defp config(key), do: Application.fetch_env!(:my_app, :krafter) |> Keyword.fetch!(key)
end

Req decodes JSON automatically, so body is already a map.

Sending email

Basic send

elixir
{:ok, %{"data" => email}, _headers} =
  MyApp.KrafterMail.request(:post, "/emails",
    json: %{
      from: "hello@yourdomain.com",
      to: ["alice@example.com"],
      subject: "Hello from Krafter Mail",
      html: "<h1>Welcome!</h1>",
      stream: "transactional"
    }
  )

IO.puts("queued: #{email["id"]} (#{email["status"]})")

Idempotency

Pass Idempotency-Key as a header, not a body field. Replays of the same key within 24 hours return the original record without re-sending. Keys must be ≤ 255 bytes.

elixir
MyApp.KrafterMail.request(:post, "/emails",
  idempotency_key: "order-#{order.id}-confirmation",
  json: %{
    from: "orders@yourdomain.com",
    to: order.customer_email,
    subject: "Your order ##{order.id}",
    html: order_html(order),
    stream: "transactional"
  }
)

Streams

Every send is routed through one of two streams:

  • transactionaltracking_enabled: false by default, no List-Unsubscribe header.
  • broadcasttracking_enabled: true by default, List-Unsubscribe header added.

If your API key is allowed to send on both streams, the stream field is required — omitting it returns 422 stream_required. If your key is restricted to a single stream, omit the field and the allowed stream is used automatically.

Per-message tracking override

Override the resolved stream's default tracking flag for a single message. Sandbox keys ignore the override (sandbox always uses a fixed policy).

elixir
MyApp.KrafterMail.request(:post, "/emails",
  json: %{
    from: "support@yourdomain.com",
    to: "alice@example.com",
    subject: "Did this help?",
    html: feedback_html(),
    stream: "transactional",
    tracking_enabled: true
  }
)

Custom MIME headers

Pass a headers map to add custom outbound headers. Reserved names (From, To, Cc, Bcc, Subject, Date, Message-ID, Content-Type, Content-Transfer-Encoding, MIME-Version, DKIM-Signature, Authorization, anything starting with X-Krafter-) return 422 forbidden_header.

elixir
MyApp.KrafterMail.request(:post, "/emails",
  json: %{
    from: "billing@yourdomain.com",
    to: "alice@example.com",
    subject: "Invoice #2048",
    html: "<p>Please find your invoice attached.</p>",
    stream: "transactional",
    headers: %{
      "X-Entity-Ref-ID" => "inv-2048"
    }
  }
)

Templates

Server-rendered templates accept template_id (UUID of a saved template) plus variables for substitution. The template's subject, html, and text replace the request fields after rendering.

elixir
MyApp.KrafterMail.request(:post, "/emails",
  json: %{
    from: "orders@yourdomain.com",
    to: "alice@example.com",
    subject: "ignored when template_id is set",
    template_id: "01J7...your-template-uuid",
    variables: %{
      "customer_name" => "Alice",
      "order_id" => "ORD-1042"
    },
    stream: "transactional"
  }
)

Batch send

elixir
{:ok, %{"data" => results}, _} =
  MyApp.KrafterMail.request(:post, "/emails/batch",
    json: %{
      emails: [
        %{from: "hello@yourdomain.com", to: "alice@example.com",
          subject: "Hi Alice", html: "<p>...</p>", stream: "transactional"},
        %{from: "hello@yourdomain.com", to: "bob@example.com",
          subject: "Hi Bob", html: "<p>...</p>", stream: "transactional"}
      ]
    }
  )

Enum.each(results, fn
  %{"status" => 201, "data" => email} -> IO.puts("queued: #{email["id"]}")
  %{"status" => _, "errors" => errors} -> IO.inspect(errors, label: "failed")
end)

The wrapper status is 201 Created if every item succeeded, 207 Multi-Status if some succeeded and some failed at insert time. Up to 100 items per batch.

Listing and retrieving

List emails

elixir
query = URI.encode_query(status: "delivered", per_page: 25)
{:ok, %{"data" => emails, "meta" => meta}, _} =
  MyApp.KrafterMail.request(:get, "/emails?" <> query)

IO.puts("page #{meta["page"]}#{meta["total"]} total")

Supported query parameters: page, per_page (capped at 100), status, search (matches from, to, or subject). Date-range filters are not supported.

Get one email

elixir
{:ok, %{"data" => email}, _} = MyApp.KrafterMail.request(:get, "/emails/#{id}")
IO.inspect({email["status"], email["delivered_at"]})

Get email events

elixir
{:ok, %{"data" => events}, _} = MyApp.KrafterMail.request(:get, "/emails/#{id}/events")
Enum.each(events, fn ev -> IO.puts("#{ev["type"]} at #{ev["occurred_at"]}") end)

Cancel a queued email

Only emails in queued status with a future scheduled_at can be cancelled.

elixir
{:ok, _, _} = MyApp.KrafterMail.request(:post, "/emails/#{id}/cancel")

Domains, templates, webhooks, statistics

The same client works for every resource. See:

Verifying webhook signatures

Krafter signs every webhook delivery with HMAC-SHA256 over the raw request body and includes the signature in the x-krafter-signature header as sha256=<hex>. Verify against the raw body — once you decode JSON, you cannot reconstruct the bytes that were signed.

In Phoenix, capture the raw body by registering a custom JSON parser hook in your endpoint or by reading the conn's :raw_body assign before parsing. The verification itself is straightforward:

elixir
defmodule MyAppWeb.Plugs.VerifyKrafterWebhook do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    secret = Application.fetch_env!(:my_app, :krafter_webhook_secret)

    with [signature_header] <- get_req_header(conn, "x-krafter-signature"),
         {:ok, raw_body, conn} <- read_raw_body(conn),
         true <- valid?(raw_body, signature_header, secret) do
      conn
    else
      _ ->
        conn
        |> put_resp_content_type("application/json")
        |> send_resp(401, ~s({"error":"Invalid signature"}))
        |> halt()
    end
  end

  defp valid?(raw_body, "sha256=" <> hex, secret), do: valid?(raw_body, hex, secret)

  defp valid?(raw_body, provided_hex, secret) do
    expected =
      :crypto.mac(:hmac, :sha256, secret, raw_body)
      |> Base.encode16(case: :lower)

    Plug.Crypto.secure_compare(expected, provided_hex)
  end

  defp read_raw_body(conn) do
    case conn.assigns[:raw_body] do
      raw when is_binary(raw) -> {:ok, raw, conn}
      _ -> Plug.Conn.read_body(conn)
    end
  end
end

The delivery also carries an x-krafter-webhook-id header with a UUID that is fresh per delivery attempt — use it as your dedup key. See Webhooks API for the full event list.

Error handling

Errors surface as one of four shapes documented in Error Handling. The wrapper above returns {:error, %{status: ..., body: ..., headers: ...}} for any non-2xx response:

elixir
case MyApp.KrafterMail.request(:post, "/emails", json: body) do
  {:ok, %{"data" => email}, _} ->
    {:ok, email}

  {:error, %{status: 401}} ->
    # %{"error" => "Invalid or missing API key"}
    {:error, :unauthorized}

  {:error, %{status: status, body: %{"code" => code, "error" => msg}}}
  when status in [403, 422] ->
    # Policy or validation error with code enum
    {:error, {code, msg}}

  {:error, %{status: 429, body: body, headers: headers}} ->
    retry_after =
      List.keyfind(headers, "retry-after", 0, {nil, "1"})
      |> elem(1)
      |> String.to_integer()

    Process.sleep(retry_after * 1_000)
    MyApp.KrafterMail.request(:post, "/emails", json: body)
end

The 429 response sets the standard Retry-After header (seconds) and includes retry_after in the body. The full policy code enum (stream_required, stream_not_allowed, forbidden_header, sandbox_extra_recipients, recipient_not_allowed, …) is listed in Error Handling.

Phoenix snippets

Sending from a controller

elixir
defmodule MyAppWeb.OrderController do
  use MyAppWeb, :controller

  def create(conn, %{"order" => order_params}) do
    with {:ok, order} <- Orders.create_order(order_params),
         {:ok, _email_id} <- send_confirmation(order) do
      conn
      |> put_flash(:info, "Order placed! Confirmation email sent.")
      |> redirect(to: ~p"/orders/#{order}")
    end
  end

  defp send_confirmation(order) do
    case MyApp.KrafterMail.request(:post, "/emails",
           idempotency_key: "order-#{order.id}-confirmation",
           json: %{
             from: "orders@yourdomain.com",
             to: order.customer_email,
             subject: "Your order ##{order.id}",
             html: render_order_email(order),
             stream: "transactional"
           }
         ) do
      {:ok, %{"data" => %{"id" => id}}, _} -> {:ok, id}
      {:error, reason} -> {:error, reason}
    end
  end
end

Background sending with Oban

elixir
defmodule MyApp.Workers.SendEmail do
  use Oban.Worker, queue: :mail, max_attempts: 5

  @impl Oban.Worker
  def perform(%Oban.Job{args: args, attempt: attempt}) do
    case MyApp.KrafterMail.request(:post, "/emails",
           idempotency_key: args["idempotency_key"],
           json: Map.delete(args, "idempotency_key")
         ) do
      {:ok, _body, _headers} ->
        :ok

      {:error, %{status: 429, headers: headers}} ->
        retry_after =
          headers
          |> List.keyfind("retry-after", 0, {nil, "1"})
          |> elem(1)
          |> String.to_integer()

        {:snooze, retry_after}

      {:error, %{status: status}} when status in 500..599 ->
        # Let Oban retry on transient server errors
        {:error, :upstream_error}

      {:error, %{status: status, body: body}} ->
        # 4xx — permanent failure, don't retry
        {:cancel, {status, body}}
    end
  end
end

Built by Krafter Studio