This guide gives each parcel's durable execution an HTTP location of its own, so a van's scanner can post events straight to it, and lets the chart send over HTTP in turn. It starts from a configuration the router delivers with, a host controller that can take a POST, and an executor of the host's own.
The protocol is the W3C Basic HTTP Event I/O Processor (SCXML appendix C.2). statifier ships the processor and a pure decoder for the POST; this package adds the location of a persisted execution and the front that delivers to it (ADR-0002, the Amendment of 2026-09-30).
Two facts to hold before you start:
A location is a bearer capability. Anyone who holds it can post events to that execution, and the router authenticates nothing beyond possession of the location. Hand a location only to the parties that should reach the execution, keep it out of logs and out of URLs shown to others, serve the base URL over TLS, and rotate it when it may have leaked.
The token is part of the execution's persisted state. The location is
written into the execution's _ioprocessors when the execution starts, and a
persisted position carries it, so statifier_persistence binds the token on the
create and on every step, and keeps it at rest in the clear unless the host
passes that package an encrypting :blob_type. This package keeps the token
out of its own query log only: its three statements that bind it run with
Ecto's log: false, and the repo's query telemetry event still carries their
parameters. Rotate a location that may have leaked, and never run a
production repo or logger at :debug (ADR-0002, the Note of 2026-10-02 on the
query log).
Step 1. Create the location table
The location table is opt-in and outside the version walk. Add the migration
in Upgrading the tables ("The opt-in location table") after
the ones you have, and run it. It worked when the table exists under your
:table_prefix.
Step 2. Set :basichttp on the configuration
Set :basichttp with the base URL the front answers at:
StatifierRouter.Config.new(
repo: MyApp.Repo,
# ... the delivery options ...
basichttp: [base_url: "https://depot.example/scxml"]
)From then on each execution created under a new address row gets a location:
the base URL, /, and a 43-character token the router mints, never the
execution id. The chart reads it at _ioprocessors['basichttp']['location']
and can hand it to whoever should reach it; the host reads it with
StatifierRouter.BasicHTTP.location/2 and replaces it with
StatifierRouter.BasicHTTP.rotate_location/2, after which the old location
answers 404. Rotation does not reach the chart's own copy, which statifier
writes once when the execution starts: after a rotation the chart still reads
the old location. An execution created under :always_new has no address row
and no location.
Check it by routing a parcel's first scan and reading
StatifierRouter.BasicHTTP.location/2 for its execution: it answers
{:ok, location}, the base URL and a token; {:error, :no_location} means
the execution has none.
Step 3. Route POSTs to the front
The front is StatifierRouter.BasicHTTP.Front, Plug-shaped as the webhook
helper is. The host routes POST <base_url>/:token (and every other method,
for the 405) to an action like this one:
def event(conn, %{"token" => token}) do
{:ok, body, conn} =
case conn.assigns do
%{raw_body: raw_body} -> {:ok, raw_body, conn}
_body_not_parsed -> Plug.Conn.read_body(conn)
end
answer =
StatifierRouter.BasicHTTP.Front.handle(MyApp.Router.config(), %{
token: token,
method: conn.method,
content_type: List.first(Plug.Conn.get_req_header(conn, "content-type")),
body: body,
query: conn.query_string,
send_key: List.first(Plug.Conn.get_req_header(conn, "scxml-send-key"))
})
{status, headers} = StatifierRouter.BasicHTTP.Front.response(answer)
conn |> Plug.Conn.merge_resp_headers(headers) |> Plug.Conn.send_resp(status, "")
endbody is the bytes as posted. A form-encoded POST has usually been parsed
before the action runs, so the action takes the bytes the endpoint's
:body_reader kept, as the webhook front does; a body the parsers pass
unread, such as a <content> send's text/plain, it reads itself.
A parcel's execution that handed its location to the van's scanner takes each
_scxmleventname=delivered POST as the event delivered and answers 204
once it is delivered. The statuses: 204 for a delivered event or a
duplicate, 404 for a location that reaches no execution (unknown, rotated
away, or finished), 405 with Allow: POST for another method, 400 for a
body or send key the decoder refuses, and 500 for a delivery that did not
settle. A POST that carries statifier's scxml-send-key header is delivered
once per execution within the default dedupe horizon of 72 hours; one without
it is delivered every time.
Step 4. Send from a durable execution
A chart can also send with <send type="basichttp">. A durable execution has
no session to perform that send, so the effect reaches the host's executor,
and the host performs it (ADR-0002, the Amendment of 2026-10-02). The
recommended shape plans the send at the executor seam, inserts a job for it
inside the delivery's transaction, and performs it after the delivery
commits: the job commits with the step that sent, no POST leaves for a step
that rolled back, and a slow receiver holds no transaction and no lock. It is
the transactional outbox of
How to deliver a chart's sends to a sink, with
the job row as the outbox row.
The example is a parcel loaded onto the van, whose execution tells the depot's manifest desk and waits; a failed POST returns the parcel to the depot:
<state id="on_van">
<onentry>
<send id="manifest" type="basichttp" target="https://depot.example/manifests" event="parcel.loaded"/>
</onentry>
<transition event="delivered" target="doorstep"/>
<transition event="error.communication" cond="_event.sendid == 'manifest'" target="returned_to_depot"/>
</state>The executor plans and inserts. StatifierRouter.BasicHTTP.deliver/3 is
pure and answers the instructions to perform; the executor runs in the
delivery's process, so the job insert joins its transaction:
defmodule MyApp.Executor do
alias Statifier.Effect.{Send, SendDelayed}
@types ["http://www.w3.org/TR/scxml/#BasicHTTPEventProcessor", "basichttp"]
def execute({:send, %Send{type: type} = send}, %{execution_id: execution_id})
when type in @types do
ctx = %{session_id: execution_id, opts: MyApp.Router.basichttp_options()}
event = Statifier.Send.Event.build(send, execution_id)
{:ok, instructions} = StatifierRouter.BasicHTTP.deliver(send, event, ctx)
Enum.reduce_while(instructions, :ok, fn
{:handler, _module, payload}, :ok ->
MyApp.Repo.insert!(MyApp.SendJob.new(execution_id, send, payload, ctx),
on_conflict: :nothing,
conflict_target: [:key]
)
{:cont, :ok}
_not_a_post, :ok ->
{:halt, {:error, {:basichttp_send_not_planned, send.send_id}}}
end)
end
# A delayed BasicHTTP send is not delivered by this package (ADR-0002,
# the Note of 2026-10-04): refuse it, and the chart sees
# error.communication with _event.sendid the send's id and no data.
def execute({:send_delayed, %SendDelayed{type: type} = send}, _context)
when type in @types,
do: {:error, {:delayed_basichttp_send, send.send_id}}
def execute(_effect, _context), do: :ok
endMyApp.SendJob.new/4 writes the payload and the context out as the outbox
example writes its event, :erlang.term_to_binary/1, beside the execution
id, the send's id and the job's key; MyApp.Router.basichttp_options/0 is
the host's own accessor for the :basichttp options it configured. The key is
the send's dedup key written out - the execution id, send_id, macrostep,
microstep, round, c_index, owner and ordinal, each nil written as
a fixed spelling of its own - so a redriven step, which re-emits the same send
with the same fields, inserts one job. A send with no target plans no POST,
and the executor's {:error, _} for it reaches the execution as
error.communication.
Step 5. Perform the job after the commit, and bring a failure back
The worker sees only committed jobs. A failure comes back through
StatifierRouter.Delivery.deliver_event/4:
defmodule MyApp.SendJob.Worker do
alias StatifierRouter.{Addresses, BasicHTTP, Delivery}
@plan %{
id: "basichttp_failure",
create: :never,
dedupe: %{by: :message_id, horizon_ms: 259_200_000}
}
def perform(job) do
{payload, ctx} = :erlang.binary_to_term(job.instruction)
case BasicHTTP.perform(payload, ctx) do
:ok -> :ok
{:error, reason} -> failed(job, reason)
end
end
# Called once the job's own retries are spent, or at once.
defp failed(job, reason) do
case Addresses.by_execution(MyApp.Router.config(), job.execution_id) do
nil ->
MyApp.DeadLetters.record(job.key, reason)
row ->
event =
Statifier.Event.external("error.communication",
sendid: job.send_id,
data: %{"reason" => inspect(reason)}
)
MyApp.Router.config()
|> Delivery.deliver_event(Map.put(@plan, :document, row.document), row.key, %{
event: event,
message_id: job.key,
scope: row.scope,
run_in_scope: true,
now: DateTime.utc_now()
})
|> settled(job, reason)
end
end
defp settled({:delivered, _plan, _execution_id}, _job, _reason), do: :ok
defp settled({:duplicate, _plan}, _job, _reason), do: :ok
defp settled({:dropped, _plan, _why}, job, reason), do: MyApp.DeadLetters.record(job.key, reason)
defp settled({:error, _reason} = error, _job, _reason_sent), do: error
enddeliver_event/4 is the only way back in: it takes the same dedupe claim, the
same address lookup and writes the same ledger row as every delivery, under
the plan's id. Name that id yourself: neither execution nor
basichttp, and no binding's. create: :never because a failure reaches an
execution that exists and never makes one. The event arrives as an external
event in a step of its own, carrying the failed send's id in
_event.sendid. A retried job's second delivery is
{:duplicate, "basichttp_failure"}. An execution that has already finished
answers {:dropped, "basichttp_failure", :finished}, and one whose address
row is gone answers nil from by_execution/2 or :no_execution from the
delivery: the miss is then a dead letter, keyed by the job's key.
{:error, _} is a delivery that did not settle, and the job retries it. A
chart with no transition for error.communication where it stands still
answers {:delivered, ...}: the event is in its input log, and nothing took
it.
run_in_scope: true runs the step the delivery causes under the address
row's scope, as a binding's delivery does, so a send the chart's
error.communication handler makes to a route that a scope in
:route_overrides overrides resolves in that scope (ADR-0002, the Amendment
of 2026-10-04). Without it, deliver_event/4 sets no scope of its own, and
such a send is refused as {:no_delivery_scope, name} in that step. The scope
is set for the length of the call only: any scope the calling process held
before is put back.
Performing inline is allowed. The executor may call
StatifierRouter.BasicHTTP.perform/2 itself and answer its
{:error, reason}, which reaches the execution as error.communication
within the same step, with no job and no deliver_event/4. The POST is then
made inside the delivery's transaction: a slow receiver holds the
transaction, the execution's lock and, on SQLite, the database's write lock,
and a delivery that rolls back after the POST has sent it anyway.