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:

  • transactional — tracking_enabled: false by default, no List-Unsubscribe header.
  • broadcast — tracking_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(:delete, "/emails/#{id}")

Domains, templates, webhooks, statistics ​

The same client works for every resource. See:

Verifying webhook signatures ​

Krafter signs every webhook delivery with HMAC-SHA256 over <t>.<raw body> and sends it in the x-krafter-signature header as t=<unix>,v1=<hex> — see Verifying signatures for the scheme and the reason t is inside the signed payload. 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

  @tolerance_seconds 300

  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, header, secret) do
    with %{"t" => t_str, "v1" => provided} <- parse(header),
         {t, ""} <- Integer.parse(t_str),
         # Freshness first: a valid signature on a stale request is still a replay,
         # and `t` cannot be rewritten because it is inside the signed payload.
         true <- abs(System.system_time(:second) - t) <= @tolerance_seconds do
      expected =
        :crypto.mac(:hmac, :sha256, secret, "#{t}.#{raw_body}")
        |> Base.encode16(case: :lower)

      Plug.Crypto.secure_compare(provided, expected)
    else
      _ -> false
    end
  end

  defp parse(header) do
    header
    |> String.split(",")
    |> Map.new(fn part ->
      case part |> String.trim() |> String.split("=", parts: 2) do
        [k, v] -> {k, v}
        [k] -> {k, ""}
      end
    end)
  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