August 27, 2026 · Tutorial
Add E-Signatures to Elixir & Phoenix LiveView via REST API (2026)
The Erlang VM (BEAM) and Phoenix LiveView are celebrated for soft real-time concurrency, resilient fault tolerance, and server-rendered reactivity. Here is how to embed end-to-end e-signature workflows into your Elixir application using Req, timing-safe HMAC webhook verification, and Phoenix.PubSub live UI synchronization.
Founder, Signbee
TL;DR
You do not need heavyweight vendor SDKs or complex single-page frontend architectures to implement live e-signatures in Elixir. Using modern Req HTTP pipelines with typed structs and pattern matching, you can dispatch Markdown contracts to Signbee's REST API in a few lines of code. When the document is signed, verify incoming webhooks with :crypto.mac/4 and Plug.Crypto.secure_compare/2, broadcast the event over Phoenix.PubSub, and watch connected LiveView sessions instantly render completed status badges and PDF download links without writing a single line of client-side JavaScript.
Why Elixir & Phoenix LiveView Excel at Document Signing Workflows
E-signature integrations are inherently asynchronous, multi-actor state machines. A user drafts an agreement, an external recipient receives an invitation, signs the document on a mobile browser, and your application must immediately update dashboard statuses, notify administrators, and archive audit certificates.
In traditional stacks (e.g. Node.js or Ruby on Rails with separate React frontends), orchestrating real-time state requires setting up polling loops, Redis WebSocket sidecars, or complex client-side state managers. If you are exploring implementations in other ecosystems, you can review our companion guides for Ruby on Rails e-signature API integration, Go (Golang) REST API signing, and our foundational E-Signature Webhook Security & Architecture Guide.
In Phoenix LiveView, however, state synchronization is a native first-class capability. Every connected browser tab is backed by an isolated Erlang process (a lightweight GenServer). When an external webhook arrives, Phoenix.PubSub broadcasts a message across the cluster, and the connected LiveView processes compute minimal HTML diffs on the server and stream updates directly down persistent WebSockets.
Prerequisites & Environment Setup
Add req and jason to your mix.exs dependencies if you have not already:
defp deps do
[
{:phoenix, "~> 1.7.14"},
{:phoenix_live_view, "~> 1.0.0"},
{:phoenix_pubsub, "~> 2.1"},
{:req, "~> 0.5.6"},
{:jason, "~> 1.4"},
{:plug_crypto, "~> 2.1"}
]
endStep 1: Defining Type-Safe Structs & Pattern Matching Schemas
Idiomatic Elixir code leverages typed structs (defstruct) paired with type specifications (@type) to provide clarity, enable Dialyzer static analysis, and facilitate robust pattern matching across your domain boundaries.
Let us define three clean structs under a Signbee namespace: DocumentRequest for outbound contracts, DocumentResponse for successful API dispatches, and WebhookEvent for incoming callbacks.
defmodule MyApp.Signbee.DocumentRequest do
@moduledoc """
Defines the parameters required to dispatch a contract for e-signature.
"""
@derive {Jason.Encoder, only: [:markdown, :recipient_name, :recipient_email, :expiration_days, :metadata]}
defstruct [:markdown, :recipient_name, :recipient_email, :expiration_days, :metadata]
@type t :: %__MODULE__{
markdown: String.t(),
recipient_name: String.t(),
recipient_email: String.t(),
expiration_days: pos_integer() | nil,
metadata: map() | nil
}
end
defmodule MyApp.Signbee.DocumentResponse do
@moduledoc """
Represents a successfully created document from the Signbee REST API.
"""
@derive Jason.Encoder
defstruct [:document_id, :signing_url, :status, :created_at]
@type t :: %__MODULE__{
document_id: String.t(),
signing_url: String.t(),
status: String.t(),
created_at: String.t()
}
@doc "Builds a DocumentResponse struct from a raw API map."
@spec from_map(map()) :: t()
def from_map(%{"document_id" => id, "signing_url" => url, "status" => status, "created_at" => created_at}) do
%__MODULE__{
document_id: id,
signing_url: url,
status: status,
created_at: created_at
}
end
end
defmodule MyApp.Signbee.WebhookEvent do
@moduledoc """
Represents a verified incoming real-time signature webhook event.
"""
defstruct [:event, :timestamp, :document_id, :signer_name, :signer_email, :signed_pdf_url, :raw_payload]
@type t :: %__MODULE__{
event: String.t(),
timestamp: String.t(),
document_id: String.t(),
signer_name: String.t() | nil,
signer_email: String.t() | nil,
signed_pdf_url: String.t() | nil,
raw_payload: map()
}
@spec parse(map()) :: t()
def parse(%{"event" => event, "timestamp" => ts, "data" => data} = raw) do
%__MODULE__{
event: event,
timestamp: ts,
document_id: data["document_id"],
signer_name: data["signer_name"],
signer_email: data["signer_email"],
signed_pdf_url: data["signed_pdf_url"],
raw_payload: raw
}
end
endStep 2: Building the Idiomatic Elixir REST Client with `Req`
Req is the modern de facto HTTP client in the Elixir ecosystem. Built on top of Mint, Finch, and NimblePool, it provides seamless connection pooling, automatic JSON encoding/decoding, exponential retries, and high-performance pipeline steps.
We encapsulate the API calls inside MyApp.Signbee.Client, returning explicit {:ok, %DocumentResponse{}} or {:error, reason} tagged tuples:
defmodule MyApp.Signbee.Client do
@moduledoc """
High-performance REST API client for dispatching e-signature contracts via Signbee.
"""
alias MyApp.Signbee.{DocumentRequest, DocumentResponse}
@base_url "https://signb.ee/api/v1"
@default_timeout :timer.seconds(30)
@doc """
Sends a markdown agreement to a recipient for e-signature.
## Examples
iex> req = %DocumentRequest{
...> markdown: "# Master Services Agreement\n\nContract terms...",
...> recipient_name: "Jane Doe",
...> recipient_email: "jane@example.com"
...> }
iex> MyApp.Signbee.Client.send_document(req)
{:ok, %MyApp.Signbee.DocumentResponse{document_id: "doc_99a8b7c6", ...}}
"""
@spec send_document(DocumentRequest.t()) :: {:ok, DocumentResponse.t()} | {:error, term()}
def send_document(%DocumentRequest{} = document_request) do
api_key = get_api_key!()
payload = %{
markdown: document_request.markdown,
recipient_name: document_request.recipient_name,
recipient_email: document_request.recipient_email,
expiration_days: document_request.expiration_days,
metadata: document_request.metadata
}
req =
Req.new(
base_url: @base_url,
auth: {:bearer, api_key},
receive_timeout: @default_timeout,
retry: :safe_transient,
max_retries: 3
)
case Req.post(req, url: "/send", json: payload) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, DocumentResponse.from_map(body)}
{:ok, %Req.Response{status: 401}} ->
{:error, :unauthorized}
{:ok, %Req.Response{status: 422, body: body}} ->
{:error, {:validation_error, body}}
{:ok, %Req.Response{status: 429}} ->
{:error, :rate_limited}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {:http_error, status, body}}
{:error, %Req.TransportError{reason: reason}} ->
{:error, {:transport_error, reason}}
{:error, reason} ->
{:error, reason}
end
end
@doc "Retrieves the status or signed details of an existing document."
@spec get_document(String.t()) :: {:ok, map()} | {:error, term()}
def get_document(document_id) when is_binary(document_id) do
api_key = get_api_key!()
req = Req.new(base_url: @base_url, auth: {:bearer, api_key})
case Req.get(req, url: "/documents/#{document_id}") do
{:ok, %Req.Response{status: 200, body: body}} -> {:ok, body}
{:ok, %Req.Response{status: 404}} -> {:error, :not_found}
{:ok, %Req.Response{status: status, body: body}} -> {:error, {:http_error, status, body}}
{:error, reason} -> {:error, reason}
end
end
defp get_api_key! do
System.get_env("SIGNBEE_API_KEY") ||
Application.get_env(:my_app, :signbee_api_key) ||
raise "SIGNBEE_API_KEY environment variable is not configured"
end
endStep 3: Configuring Raw Body Caching in Phoenix `Plug.Parsers`
A frequent pitfall when handling cryptographic webhooks in Phoenix is that Plug.Parsers decodes the JSON payload into an Elixir map and drops the original binary from memory. Because HMAC-SHA256 signatures are calculated against the exact raw byte string transmitted over HTTP, attempting to re-encode a decoded map with Jason.encode/1 will often produce whitespace or key sorting discrepancies that cause signature validation to fail.
To resolve this cleanly, create a custom CacheBodyReader plug module and register it in endpoint.ex:
defmodule MyAppWeb.Plugs.CacheBodyReader do
@moduledoc """
Custom body reader plug that caches the untouched raw request binary in conn.assigns[:raw_body].
Required for HMAC-SHA256 signature verification.
"""
@behaviour Plug
def init(opts), do: opts
def call(conn, _opts), do: conn
@doc """
Reads the raw body chunk and stores it in conn.assigns for webhook routes.
"""
def read_body(conn, opts) do
case Plug.Conn.read_body(conn, opts) do
{:ok, body, conn} ->
conn = update_in(conn.assigns[:raw_body], &[body | &1 || []])
{:ok, body, conn}
{:more, body, conn} ->
conn = update_in(conn.assigns[:raw_body], &[body | &1 || []])
{:more, body, conn}
{:error, reason} ->
{:error, reason}
end
end
endNext, update your lib/my_app_web/endpoint.ex to pass body_reader: {MyAppWeb.Plugs.CacheBodyReader, :read_body, []} to Plug.Parsers:
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
body_reader: {MyAppWeb.Plugs.CacheBodyReader, :read_body, []},
json_decoder: Phoenix.json_library()Step 4: Building the Webhook Controller with Constant-Time HMAC Verification
When Signbee dispatches a webhook callback (such as document.viewed or document.signed), it attaches an X-Signbee-Signature header containing an HMAC-SHA256 hex digest of the raw request payload.
In Elixir, we compute the HMAC using Erlang's built-in :crypto.mac(:hmac, :sha256, secret, raw_body) and compare the resulting hex string against the incoming header using Plug.Crypto.secure_compare/2. This guarantees constant-time comparison, eliminating timing side-channel attack vectors.
defmodule MyAppWeb.SignbeeWebhookController do
use MyAppWeb, :controller
require Logger
alias MyApp.Signbee.WebhookEvent
@doc """
Handles incoming Signbee webhook events, verifies HMAC signatures, and broadcasts
updates across Phoenix.PubSub to notify connected LiveView sessions in real time.
"""
def handle(conn, params) do
raw_body = get_cached_raw_body(conn)
signature_header = get_req_header(conn, "x-signbee-signature") |> List.first()
webhook_secret = get_webhook_secret()
with :ok <- verify_signature(raw_body, signature_header, webhook_secret) do
event = WebhookEvent.parse(params)
Logger.info("[Signbee Webhook] Received #{event.event} for document #{event.document_id}")
# Broadcast to PubSub topic for real-time LiveView synchronization
topic = "documents:#{event.document_id}"
Phoenix.PubSub.broadcast(MyApp.PubSub, topic, {:signbee_event, event})
# Also broadcast to global tenant or admin topic
Phoenix.PubSub.broadcast(MyApp.PubSub, "documents:all", {:signbee_event, event})
# Trigger asynchronous processing (e.g., download signed PDF, update Ecto schema)
handle_event_async(event)
conn
|> put_status(:ok)
|> json(%{status: "received", document_id: event.document_id})
else
{:error, :invalid_signature} ->
Logger.warning("[Signbee Webhook] Invalid HMAC signature rejected")
conn
|> put_status(:unauthorized)
|> json(%{error: "Invalid webhook signature"})
{:error, :missing_signature} ->
Logger.warning("[Signbee Webhook] Missing X-Signbee-Signature header")
conn
|> put_status(:bad_request)
|> json(%{error: "Missing signature header"})
end
end
defp verify_signature(_raw_body, nil, _secret), do: {:error, :missing_signature}
defp verify_signature(_raw_body, _sig, nil), do: {:error, :missing_secret}
defp verify_signature(raw_body, signature_header, secret) do
# Compute HMAC-SHA256 digest
computed_mac = :crypto.mac(:hmac, :sha256, secret, raw_body)
expected_signature = Base.encode16(computed_mac, case: :lower)
# Use constant-time comparison to prevent timing attacks
if Plug.Crypto.secure_compare(expected_signature, String.downcase(signature_header)) do
:ok
else
{:error, :invalid_signature}
end
end
defp get_cached_raw_body(conn) do
case conn.assigns[:raw_body] do
chunks when is_list(chunks) -> IO.iodata_to_binary(Enum.reverse(chunks))
binary when is_binary(binary) -> binary
_ -> ""
end
end
defp handle_event_async(%WebhookEvent{event: "document.signed"} = event) do
# Offload PDF download and database archival to a supervised Task
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
MyApp.Documents.complete_signing_process(event.document_id, event.signed_pdf_url)
end)
end
defp handle_event_async(_event), do: :ok
defp get_webhook_secret do
System.get_env("SIGNBEE_WEBHOOK_SECRET") ||
Application.get_env(:my_app, :signbee_webhook_secret)
end
endRoute the webhook endpoint in your lib/my_app_web/router.ex:
scope "/api/webhooks", MyAppWeb do pipe_through :api post "/signbee", SignbeeWebhookController, :handle end
Step 5: Wiring Real-Time UI Updates into Phoenix LiveView
With Phoenix.PubSub broadcasting incoming webhook events, wiring dynamic real-time UI updates into LiveView requires zero JavaScript build tooling.
In the LiveView module below, when a user views a document contract page, the LiveView mounts and subscribes to "documents:#{document_id}" if the socket is connected. When the document is signed on a remote device, the LiveView receives {:signbee_event, %WebhookEvent{event: "document.signed"}} in handle_info/2, patches the socket assigns, and pushes instantaneous visual updates to the client:
defmodule MyAppWeb.DocumentLive.Show do
use MyAppWeb, :live_view
alias MyApp.Documents
alias MyApp.Signbee.WebhookEvent
@impl true
def mount(%{"id" => document_id}, _session, socket) do
if connected?(socket) do
# Subscribe to document-specific PubSub topic
Phoenix.PubSub.subscribe(MyApp.PubSub, "documents:#{document_id}")
end
document = Documents.get_document!(document_id)
{:ok,
socket
|> assign(:document, document)
|> assign(:page_title, "Contract: #{document.title}")
|> assign(:status, document.status)}
end
@impl true
def handle_info({:signbee_event, %WebhookEvent{event: "document.signed"} = event}, socket) do
# Real-time state update pushed down WebSocket
updated_doc = %{
socket.assigns.document
| status: "signed",
signed_pdf_url: event.signed_pdf_url,
signer_name: event.signer_name
}
{:noreply,
socket
|> assign(:document, updated_doc)
|> assign(:status, "signed")
|> put_flash(:info, "Document was successfully signed by #{event.signer_name}!")}
end
@impl true
def handle_info({:signbee_event, %WebhookEvent{event: "document.viewed"}}, socket) do
{:noreply,
socket
|> assign(:status, "viewed")
|> put_flash(:info, "Recipient opened and viewed the document.")}
end
@impl true
def handle_info({:signbee_event, _other_event}, socket) do
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<div class="max-w-3xl mx-auto py-8 px-4">
<div class="flex items-center justify-between border-b border-zinc-800 pb-6 mb-6">
<div>
<h1 class="text-2xl font-bold text-white tracking-tight">{@document.title}</h1>
<p class="text-sm text-zinc-400 mt-1">Recipient: {@document.recipient_email}</p>
</div>
<div>
<%= case @status do %>
<% "signed" -> %>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 animate-pulse">
✓ Signed & Legally Binding
</span>
<% "viewed" -> %>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
👁 Viewed by Recipient
</span>
<% _ -> %>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
⏳ Awaiting Signature
</span>
<% end %>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-6 mb-6">
<h3 class="text-sm font-semibold text-white mb-2">Signing Progress</h3>
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-zinc-400">Document ID</span>
<span class="font-mono text-zinc-200">{@document.id}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-zinc-400">Signing Link</span>
<a href={@document.signing_url} target="_blank" class="text-amber-400 hover:underline font-mono text-xs">
{@document.signing_url}
</a>
</div>
<%= if @status == "signed" do %>
<div class="flex justify-between text-sm pt-3 border-t border-zinc-800">
<span class="text-zinc-400">Signed Audit PDF</span>
<a href={@document.signed_pdf_url} download class="inline-flex items-center gap-1 text-emerald-400 hover:underline font-medium text-xs">
Download Cryptographic Certificate ↓
</a>
</div>
<% end %>
</div>
</div>
</div>
"""
end
endStep 6: Resilient Background Archival with Tasks or Oban
When a document is completed, your application must download the rendered PDF and SHA-256 certificate for long-term record-keeping. Under high throughput, running network I/O inside your webhook request thread is anti-pattern because it delays returning 200 OK to the webhook emitter.
In Elixir, you can achieve resilient asynchronous handling using a supervised Task or a persistent job queue like Oban:
defmodule MyApp.Documents.Archiver do
@moduledoc """
Fetches signed PDFs and cryptographic audit logs from Signbee and saves to local or S3 storage.
"""
require Logger
@doc "Asynchronously downloads and archives signed PDF documents."
def archive_signed_document(document_id, download_url) do
Logger.info("[Archiver] Starting download for #{document_id}")
case Req.get(download_url, receive_timeout: 60_000) do
{:ok, %Req.Response{status: 200, body: pdf_binary}} ->
# Calculate SHA-256 hash of downloaded PDF for audit integrity
sha256_hash = :crypto.hash(:sha256, pdf_binary) |> Base.encode16(case: :lower)
# Store in S3 / Waffle / Local Disk
storage_path = "contracts/#{document_id}_#{sha256_hash}.pdf"
File.write!(storage_path, pdf_binary)
Logger.info("[Archiver] Successfully archived document #{document_id} (SHA256: #{sha256_hash})")
{:ok, storage_path}
{:error, reason} ->
Logger.error("[Archiver] Failed to download signed PDF for #{document_id}: #{inspect(reason)}")
{:error, reason}
end
end
endFramework Comparison: Phoenix LiveView vs. Alternatives
Here is how integrating real-time e-signatures in Phoenix LiveView compares to other web architectures:
| Framework / Architecture | Real-Time Transport | HMAC Verification | Client JS Overhead |
|---|---|---|---|
| Phoenix LiveView | BEAM WebSocket & PubSub | Plug.Crypto.secure_compare | 0 kB custom JavaScript |
| Ruby on Rails (Hotwire / Turbo) | ActionCable & Redis | ActiveSupport::SecurityUtils | Low (~30 kB Turbo runtime) |
| Next.js + Node.js API | Server-Sent Events / Pusher | crypto.timingSafeEqual | High (Full React bundle & state hooks) |
| Go (Golang) + HTMX | SSE or WebSockets | subtle.ConstantTimeCompare | Minimal (~14 kB HTMX) |
Frequently Asked Questions
How does Phoenix LiveView handle real-time e-signature status updates without frontend JavaScript frameworks?
Phoenix LiveView manages real-time document signing states by leveraging Erlang VM (BEAM) lightweight processes and Phoenix.PubSub rather than requiring heavy single-page application (SPA) client frameworks like React or Vue. When a user views a document status page in LiveView, the connected LiveView process subscribes to a specific document topic (such as documents:doc_xyz123) over a persistent WebSocket connection. When Signbee sends a signed webhook event to your Phoenix webhook controller, the controller verifies the HMAC signature and broadcasts a PubSub message across your application cluster. The connected LiveView process receives this message in its handle_info/2 callback, updates its socket assigns, calculates minimal HTML diffs on the server, and pushes only the changed DOM nodes over the WebSocket. The user's browser updates instantaneously from "Awaiting Signature" to "Signed & Verified" with zero custom client-side JavaScript.
Why is raw request body caching necessary for HMAC webhook verification in Phoenix, and how does Plug.Crypto.secure_compare protect against timing attacks?
In Phoenix applications, the default Plug.Parsers middleware parses incoming JSON request bodies into Elixir map parameters and discards the original raw binary payload from memory. Because cryptographic HMAC-SHA256 signatures are calculated over the exact raw byte stream sent by the API provider, attempting to re-encode parsed parameters back to JSON creates subtle whitespace or key ordering differences that invalidate the digest. Configuring a custom body reader plug caches the untouched binary in conn.assigns[:raw_body] before JSON parsing occurs. Once the expected HMAC digest is computed using :crypto.mac(:hmac, :sha256, secret, raw_body), developers must use Plug.Crypto.secure_compare/2 rather than Elixir's standard == comparison operator. Standard equality operations abort at the first non-matching byte, leaking execution timing clues that allow attackers to forge webhook signatures. Plug.Crypto.secure_compare executes in constant time, neutralizing timing side-channel vulnerabilities entirely.
How does Elixir's OTP supervision tree and Req improve resilience when dispatching and receiving document signature webhooks?
Elixir and the BEAM runtime offer unmatched fault tolerance through OTP supervision trees and isolated actor concurrency. When dispatching contract requests using modern HTTP clients like Req, outbound calls benefit from automatic retry policies, circuit breakers, and connection pooling that prevent external network hiccups from bringing down web request threads. For incoming webhooks, long-running operations—such as fetching signed PDFs or archiving SHA-256 cryptographic audit logs—can be offloaded to isolated OTP Tasks, Task.Supervisor, or persistent job queues like Oban. If an archive task fails due to temporary third-party storage downtime, OTP supervisors isolate the crash, trigger exponential backoff retries, and keep the main Phoenix web server fully operational. This architecture ensures that your application responds with an immediate HTTP 200 OK to the webhook provider while reliably completing background processing tasks asynchronously.
Ready to integrate e-signatures into your Phoenix application? Get 5 free documents/month.
Last updated: August 27, 2026 · Michael Beckett is the founder of Signbee and B2bee Ltd.