Appearance
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"}
]
endRead 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)
endReq 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: falseby default, noList-Unsubscribeheader.broadcast—tracking_enabled: trueby default,List-Unsubscribeheader 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:
- Domains API —
GET/POST /mail/domains,POST /mail/domains/:id/verify. - Templates API —
GET/POST /mail/templates, etc. - Webhooks API — endpoints live under
/api/v1/mail/webhooks/*. - Statistics API —
GET /mail/statsandGET /mail/stats/daily?days=N.
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
endThe 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)
endThe 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
endBackground 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