Phoenix
Validate documents with Constaia from Elixir and Phoenix using Req and form_multipart, Plug.Upload and webhooks verified over the raw body.
This guide integrates Constaia into a Phoenix app: a module that calls the REST API with Req,
a controller that receives a %Plug.Upload{} and forwards it, and a webhook verified over the raw body, which Phoenix
does not keep by default.
There is no official Elixir SDK: you call the REST API directly. If you prefer a generated client, the OpenAPI spec is
at https://api.constaia.com/openapi.json (for example, with the elixir generator of openapi-generator).
Requirements
- Elixir 1.15 or later and Phoenix 1.7 or 1.8.
- A test key
ck_test_…from the dashboard (see Authentication). - The
whsec_…secret of a webhook endpoint (see Webhooks).
Installation
defp deps do
[
# …your Phoenix dependencies (includes :jason)
{:req, "~> 0.5"}
]
endmix deps.getConfiguration
config :my_app,
constaia_api_key: System.get_env("CONSTAIA_API_KEY"),
constaia_webhook_secret: System.get_env("CONSTAIA_WEBHOOK_SECRET")CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...The key stays on the server; never send it to LiveView JavaScript hooks or any client.
Client
form_multipart sends the file part (with its filename) and the options part (JSON as text). Every call carries
an Idempotency-Key; since Req retries the same request, the key is kept and there is no double charge. The retry
function only retries 429 responses, and Req waits as long as Retry-After says.
defmodule MyApp.Constaia do
@base_url "https://api.constaia.com"
defmodule Error do
defexception [:status, :type, :code, :message, :param, :request_id]
end
# {:ok, analysis}: analysis["status"] is "completed" (HTTP 200) or "queued"/"processing" (HTTP 202).
def analyze(%Plug.Upload{} = upload, options) do
content = File.read!(upload.path)
client()
|> Req.post(
url: "/v1/analyze",
headers: %{"idempotency-key" => Ecto.UUID.generate()},
form_multipart: [
file: {content, filename: upload.filename, content_type: upload.content_type || "application/octet-stream"},
options: Jason.encode!(options)
]
)
|> handle()
end
def get_analysis(id) do
client() |> Req.get(url: "/v1/analyses/#{id}") |> handle()
end
defp client do
Req.new(
base_url: @base_url,
auth: {:bearer, Application.fetch_env!(:my_app, :constaia_api_key)},
# A synchronous analysis can take up to 30 s before returning 202.
receive_timeout: 60_000,
retry: &retry_rate_limited?/2,
max_retries: 2
)
end
defp retry_rate_limited?(_request, %Req.Response{status: 429}), do: true
defp retry_rate_limited?(_request, _response_or_exception), do: false
defp handle({:ok, %Req.Response{status: status, body: body}}) when status in 200..299, do: {:ok, body}
defp handle({:ok, %Req.Response{status: status, body: body} = response}) do
error = if is_map(body), do: body["error"] || %{}, else: %{}
{:error,
%Error{
status: status,
type: error["type"],
code: error["code"],
message: error["message"] || "HTTP #{status}",
param: error["param"],
request_id: error["request_id"] || List.first(Req.Response.get_header(response, "x-request-id"))
}}
end
defp handle({:error, exception}), do: {:error, exception}
endEcto.UUID.generate/0 comes with Ecto. If your app doesn't use Ecto, any random string up to 255 characters works,
for example Base.encode16(:crypto.strong_rand_bytes(16), case: :lower).
Controller that receives the document
Keep upload.filename: in test mode the result depends on the name. Your backend decides expect and checks;
don't take them as-is from the client.
defmodule MyAppWeb.DocumentVerificationController do
use MyAppWeb, :controller
require Logger
alias MyApp.Constaia
@max_bytes 20 * 1024 * 1024
# Add your authentication here: every analysis spends credits.
def create(conn, %{"file" => %Plug.Upload{} = upload} = params) do
%{size: size} = File.stat!(upload.path)
cond do
size == 0 -> render_error(conn, 400, "The document is missing.")
size > @max_bytes -> render_error(conn, 413, "Max 20 MB.")
true -> analyze(conn, upload, params["full_name"])
end
end
def create(conn, _params), do: render_error(conn, 400, "The document is missing.")
defp analyze(conn, upload, full_name) do
options = %{
expect: "es_dni",
checks: put_holder(%{not_expired: true}, full_name),
storage: "none",
language: "en"
}
case Constaia.analyze(upload, options) do
{:ok, %{"status" => "completed"} = analysis} ->
json(conn, %{
id: analysis["id"],
status: get_in(analysis, ["verdict", "status"]),
reasons: analysis |> get_in(["verdict", "reasons"]) |> List.wrap() |> Enum.map(& &1["message"]),
warnings: analysis["warnings"]
})
{:ok, analysis} ->
# 202: the result will arrive by webhook (or call get_analysis/1).
conn |> put_status(202) |> json(%{id: analysis["id"], status: analysis["status"]})
{:error, %Constaia.Error{} = e} ->
Logger.warning("Constaia #{e.status} #{e.code} request_id=#{e.request_id}")
if e.type == "invalid_request",
do: render_error(conn, 422, e.message),
else: render_error(conn, 502, "The document could not be verified. Please try again.")
{:error, exception} ->
Logger.warning("Constaia: #{Exception.message(exception)}")
render_error(conn, 502, "The document could not be verified. Please try again.")
end
end
defp put_holder(checks, name) when is_binary(name) and name != "",
do: Map.put(checks, :holder, %{full_name: name})
defp put_holder(checks, _name), do: checks
defp render_error(conn, status, message) do
conn |> put_status(status) |> json(%{error: %{message: message}})
end
endverdict.status is:
- Válido the type matches and there are no warnings: accept.
- No válido some reason with severity
error(wrong type, expired, holder mismatch…): reject. - Revisar some reason with severity
warning: human review.
code values are stable; message values are localised according to language. For a field:
get_in(analysis, ["fields", "document_number", "value"]). More in Verdicts.
200 vs 202
A synchronous analysis waits up to 30 s. If it finishes, you get 200 with status: "completed"; if not, 202 with
status: "queued" or "processing" and the result arrives by webhook. With async: true in the options the API
answers 202 right away. analysis.review_required is sent in addition to analysis.completed when the verdict is
review.
Webhook with verified signature
Plug.Parsers consumes the body when it parses the JSON. To verify the signature you need the exact bytes, so keep
them with your own body_reader, only on the webhook route. Also raise the multipart limit, which is 8 MB by
default.
defmodule MyAppWeb.CacheBodyReader do
def read_body(%Plug.Conn{request_path: "/webhooks/constaia"} = conn, opts) do
case Plug.Conn.read_body(conn, opts) do
{:ok, body, conn} -> {:ok, body, cache(conn, body)}
{:more, body, conn} -> {:more, body, cache(conn, body)}
other -> other
end
end
def read_body(conn, opts), do: Plug.Conn.read_body(conn, opts)
defp cache(conn, body), do: update_in(conn.assigns[:raw_body], &[body | (&1 || [])])
endplug Plug.Parsers,
parsers: [:urlencoded, {:multipart, length: 21_000_000}, :json],
pass: ["*/*"],
body_reader: {MyAppWeb.CacheBodyReader, :read_body, []},
json_decoder: Phoenix.json_library()defmodule MyApp.ConstaiaWebhook do
@tolerance 300
def verify(raw, id, timestamp, signatures, secret)
when is_binary(id) and is_binary(timestamp) and is_binary(signatures) do
with {ts, ""} <- Integer.parse(timestamp),
true <- abs(System.system_time(:second) - ts) <= @tolerance,
{:ok, key} <- secret |> String.replace_prefix("whsec_", "") |> Base.decode64(),
expected = :crypto.mac(:hmac, :sha256, key, "#{id}.#{timestamp}.#{raw}") |> Base.encode64(),
true <- signatures |> String.split(" ", trim: true) |> Enum.any?(&valid_signature?(&1, expected)) do
Jason.decode(raw)
else
_ -> {:error, :invalid_signature}
end
end
def verify(_raw, _id, _timestamp, _signatures, _secret), do: {:error, :missing_headers}
defp valid_signature?("v1," <> sig, expected), do: Plug.Crypto.secure_compare(sig, expected)
defp valid_signature?(_part, _expected), do: false
enddefmodule MyAppWeb.ConstaiaWebhookController do
use MyAppWeb, :controller
require Logger
alias MyApp.ConstaiaWebhook
@analysis_events ["analysis.completed", "analysis.review_required", "analysis.failed"]
def create(conn, _params) do
raw = conn.assigns |> Map.get(:raw_body, []) |> Enum.reverse() |> IO.iodata_to_binary()
msg_id = header(conn, "webhook-id")
secret = Application.fetch_env!(:my_app, :constaia_webhook_secret)
case ConstaiaWebhook.verify(raw, msg_id, header(conn, "webhook-timestamp"), header(conn, "webhook-signature"), secret) do
{:ok, event} ->
handle_event(msg_id, event)
send_resp(conn, 204, "")
{:error, _reason} ->
send_resp(conn, 400, "invalid signature")
end
end
# webhook-id is stable across retries: dedupe on it in your database.
defp handle_event(msg_id, %{"type" => type, "data" => analysis}) when type in @analysis_events do
Logger.info("#{msg_id} #{type} #{analysis["id"]} #{get_in(analysis, ["verdict", "status"])}")
# Queue heavy work (for example, with Oban) and answer within 15 s.
end
defp handle_event(_msg_id, _event), do: :ok
defp header(conn, name), do: conn |> get_req_header(name) |> List.first()
endscope "/", MyAppWeb do
pipe_through :api
post "/api/verify", DocumentVerificationController, :create
post "/webhooks/constaia", ConstaiaWebhookController, :create
endThe :api pipeline applies no CSRF protection, so the webhook needs no exceptions. If /api/verify goes through the
:browser pipeline, send the CSRF token from your form.
Test mode
With a ck_test_… key no credits are spent and the result depends on the filename (it must be a real image or PDF:
rename any photo).
curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:4000/api/verify
curl -F "file=@dni_expired.jpg" http://localhost:4000/api/verify
curl -F "file=@blurry.jpg" http://localhost:4000/api/verify
curl -F "file=@dni_valid.jpg" -F "full_name=Juan Pérez" http://localhost:4000/api/verify| File | Verdict | Reason |
|---|---|---|
dni_valid.jpg | valid | "Valid until 12/03/2031." |
dni_expired.jpg | invalid | not_expired with severity error |
blurry.jpg | review | low_quality with severity warning; warnings blurry and low_quality |
dni_valid.jpg with full_name=Juan Pérez | invalid | holder with severity error |
More files in Test mode.
Errors
Error responses have the shape { "error": { "type", "code", "message", "param", "request_id" } }, which the
client turns into %MyApp.Constaia.Error{}. Branch on type and code, never on the message, and log request_id.
Full table in Errors.
Production checklist
- A
ck_live_…key (requires a verified email) in your secret manager or an environment variable. - Authentication and a per-user limit on
/api/verify: every analysis spends credits. - A 20 MB limit before calling the API.
- One
Idempotency-Keyper logical operation, reused on retries (Idempotency). - Limit concurrency towards Constaia (2 req/s per key on the free plan, 10 on paid), for example with
Task.async_stream/3andmax_concurrency(Rate limits). - A webhook registered for
202responses andasyncanalyses; dedupe onwebhook-idand answer fast. - Review
storageandkeep_resultsin Storage and privacy.
Next steps
Rust
Integrate Constaia in Rust with reqwest multipart, tokio and serde, an axum 0.8 handler that forwards the file and webhooks verified with hmac and sha2.
Vapor
Validate documents with Constaia from server-side Swift with Vapor 4, multipart upload with the Vapor client, Codable and HMAC webhooks with swift-crypto.