# WebTransport Guide
hackney provides a WebTransport client that mirrors the WebSocket API, so
existing code can move from WebSocket to WebTransport by swapping the `ws_`
prefix for `wt_`. It runs over HTTP/3 (QUIC) by default, with HTTP/2 as an
option, and uses the same process-per-connection model.
WebTransport is the analog of an HTTP/2 connection: one connection carries
many multiplexed streams plus unreliable datagrams.
## Quick Start
```erlang
{ok, Conn} = hackney:wt_connect(<<"https://example.com/wt">>),
ok = hackney:wt_send(Conn, {binary, <<"Hello!">>}),
{ok, {binary, <<"Hello!">>}} = hackney:wt_recv(Conn),
hackney:wt_close(Conn).
```
Compare with WebSocket; the shape is identical:
```erlang
{ok, Conn} = hackney:ws_connect(<<"wss://example.com/socket">>),
ok = hackney:ws_send(Conn, {binary, <<"Hello!">>}),
{ok, {binary, <<"Hello!">>}} = hackney:ws_recv(Conn),
hackney:ws_close(Conn).
```
## Connecting
WebTransport always runs over TLS. Use the `https://` scheme; `wss://` is
accepted as an alias so a URL can carry over unchanged.
```erlang
{ok, Conn} = hackney:wt_connect(<<"https://example.com/wt">>).
```
### Connection with Options
```erlang
{ok, Conn} = hackney:wt_connect(<<"https://example.com/wt">>, [
{transport, h3},
{connect_timeout, 5000},
{recv_timeout, 30000},
{headers, [{<<"authorization">>, <<"Bearer token">>}]}
]).
```
### Available Options
| Option | Default | Description |
|--------|---------|-------------|
| `transport` | `h3` | `h3` (QUIC) or `h2` |
| `connect_timeout` | 8000 | Session handshake timeout (ms) |
| `recv_timeout` | infinity | Default receive timeout (ms) |
| `active` | `false` | Active mode: false, true, once |
| `headers` | `[]` | Extra headers for the CONNECT request |
| `ssl_options` | `[]` | TLS options: `verify`, `cacerts`/`cacertfile`, `cert`/`certfile`, `key`/`keyfile` |
| `verify` | `verify_peer` | `verify_peer` or `verify_none` |
| `compat_mode` | `latest` | `latest` or `legacy_browser_compat` |
| `max_recv_buffer` | 67108864 | Cap (bytes) on buffered, unread data |
When verifying with no CA configured, hackney uses the bundled `certifi`
trust store, the same as for HTTPS requests.
## The Default Message Channel
`wt_send`/`wt_recv` operate on a single persistent bidirectional stream
opened at connect time. This is the drop-in replacement for the WebSocket
message channel.
```erlang
ok = hackney:wt_send(Conn, {binary, <<1, 2, 3>>}).
ok = hackney:wt_send(Conn, {text, <<"text is sent as bytes">>}).
{ok, {binary, Data}} = hackney:wt_recv(Conn).
{ok, {binary, Data}} = hackney:wt_recv(Conn, 5000). %% With timeout
```
WebTransport has no message framing of its own. To stay interoperable with
any server, hackney does not add a wire format: bytes are written to the
stream as-is, and a received chunk is returned as `{binary, Data}`. Chunks
are reliable and ordered, but are **not** guaranteed to line up with your
send boundaries. If you need message boundaries, delimit them yourself, or
use one stream per message (see below).
## Datagrams
Datagrams are unreliable, unordered, and size-limited, like UDP.
```erlang
ok = hackney:wt_send_datagram(Conn, <<"ping">>).
%% Inbound datagrams arrive on the default channel:
{ok, {datagram, Data}} = hackney:wt_recv(Conn).
```
## Multiplexed Streams
Open as many streams as you want over one session, just like HTTP/2
multiplexes requests over one connection. Each stream has its own send and
receive channel keyed by id.
```erlang
{ok, StreamId} = hackney:wt_open_stream(Conn, bidi), %% or uni
ok = hackney:wt_stream_send(Conn, StreamId, <<"request">>),
ok = hackney:wt_stream_send(Conn, StreamId, <<"!">>, fin), %% close write side
{ok, Data} = hackney:wt_stream_recv(Conn, StreamId),
{ok, {fin, Last}} = hackney:wt_stream_recv(Conn, StreamId). %% peer ended stream
```
Stream lifecycle:
```erlang
hackney:wt_close_stream(Conn, StreamId). %% graceful FIN
hackney:wt_reset_stream(Conn, StreamId, ErrorCode). %% abort
hackney:wt_stop_sending(Conn, StreamId, ErrorCode). %% ask peer to stop
```
Data on streams the server opens (rather than ones you opened) is surfaced
on the default channel as `{stream, Id, Data}` / `{stream_fin, Id, Data}`.
## Active Mode
In active mode every event is forwarded to the owner process, uniformly
tagged with its stream id.
```erlang
{ok, Conn} = hackney:wt_connect(URL, [{active, true}]),
receive
{hackney_wt, Conn, {binary, Data}} -> handle(Data);
{hackney_wt, Conn, {datagram, Data}} -> handle_dgram(Data);
{hackney_wt, Conn, {stream, Id, Data}} -> handle_stream(Id, Data);
{hackney_wt, Conn, {stream_fin, Id, Data}} -> handle_fin(Id, Data);
{hackney_wt, Conn, closed} -> done;
{hackney_wt_error, Conn, Reason} -> error
end.
```
`active, once` delivers a single message and reverts to passive:
```erlang
{ok, Conn} = hackney:wt_connect(URL, [{active, once}]),
receive {hackney_wt, Conn, Msg} -> ok end,
hackney:wt_setopts(Conn, [{active, once}]). %% arm for the next one
```
## Closing Connections
```erlang
hackney:wt_close(Conn).
hackney:wt_close(Conn, {0, <<"bye">>}). %% {ErrorCode, Reason}
```
## Server Side
hackney is the WebTransport **client**. The peer is any WebTransport
server. To run one in Erlang, use the `webtransport` library (a hackney
dependency, from the [erlang-webtransport](https://github.com/benoitc/erlang-webtransport)
project): start a listener and implement the `webtransport_handler`
behaviour. The handler callbacks are where you receive what the hackney
client sends, and the actions you return are what the client receives back.
### Handler
```erlang
-module(my_wt_handler).
-behaviour(webtransport_handler).
-export([init/3, handle_stream/4, handle_stream_fin/4,
handle_datagram/2, handle_stream_closed/3, terminate/2]).
init(_Session, _Req, _Opts) ->
{ok, #{}}.
%% A chunk arrived on a stream (no FIN yet). Reply by returning a `send'
%% action on the SAME stream id; that is what the client reads back.
handle_stream(StreamId, bidi, Data, State) ->
{ok, State, [{send, StreamId, Data}]}; %% echo
handle_stream(_StreamId, uni, _Data, State) ->
{ok, State}.
%% The peer closed its write side (FIN). For a bidi stream you can answer
%% with a final chunk and FIN to close yours.
handle_stream_fin(StreamId, bidi, Data, State) ->
{ok, State, [{send, StreamId, Data, fin}]};
handle_stream_fin(_StreamId, uni, _Data, State) ->
{ok, State}.
handle_datagram(Data, State) ->
{ok, State, [{send_datagram, Data}]}. %% echo a datagram
handle_stream_closed(_StreamId, _Reason, State) ->
{ok, State}.
terminate(_Reason, _State) ->
ok.
```
Available actions a callback may return in its `{ok, State, Actions}`
result: `{send, StreamId, Data}`, `{send, StreamId, Data, fin}`,
`{send_datagram, Data}`, `{open_stream, bidi | uni}`,
`{close_stream, StreamId}`, `{reset_stream, StreamId, Code}`,
`{stop_sending, StreamId, Code}`, `drain_session`,
`{close_session, Code, Reason}`.
### Listener
```erlang
{ok, _} = application:ensure_all_started(webtransport),
{ok, _Pid} = webtransport:start_listener(my_listener, #{
transport => h3, %% or h2
port => 4433,
certfile => "cert.pem",
keyfile => "key.pem",
handler => my_wt_handler
}).
%% ... later
ok = webtransport:stop_listener(my_listener).
```
### How client calls map to server callbacks
| hackney client | server callback | reply to client |
|----------------|-----------------|-----------------|
| `wt_send(C, {binary, D})` (default stream) | `handle_stream(Id, bidi, D, S)` | `{send, Id, D2}` → client `wt_recv` returns `{binary, D2}` |
| `wt_send_datagram(C, D)` | `handle_datagram(D, S)` | `{send_datagram, D2}` → client `wt_recv` returns `{datagram, D2}` |
| `wt_open_stream(C, bidi)` + `wt_stream_send(C, Id, D)` | `handle_stream(Id, bidi, D, S)` | `{send, Id, D2}` → client `wt_stream_recv(C, Id)` returns `{ok, D2}` |
| `wt_stream_send(C, Id, D, fin)` | `handle_stream_fin(Id, bidi, D, S)` | `{send, Id, D2, fin}` → client gets `{ok, {fin, D2}}` |
Reply on the **same stream id** you were called with to answer on that
stream. To push data the client did not ask for, return `{open_stream, ...}`
and send on the new id; the client surfaces it on the default channel as
`{stream, Id, Data}` (or `{hackney_wt, C, {stream, Id, Data}}` in active
mode).
Browsers and other WebTransport clients work against the same listener;
nothing about the server is hackney-specific.
## Differences from WebSocket
| WebSocket | WebTransport |
|-----------|--------------|
| Single ordered message channel | Many multiplexed streams + datagrams |
| Reliable, framed messages | Reliable streams (no framing) + unreliable datagrams |
| `ping`/`pong` frames | Not used (QUIC keepalive); `wt_send` returns `{error, {unsupported_frame, ping}}` |
| `ws://` / `wss://` | `https://` (TLS only) |
| HTTP/1.1 Upgrade, proxies | HTTP/3 (QUIC) or HTTP/2, no proxy |
`wt_recv`/`wt_stream_recv` return `{error, {active_mode, Mode}}` in active
mode, `{error, timeout}` on timeout, and `{error, closed}` once the session
ends and buffered data is drained.
## Example: Echo Client
```erlang
-module(wt_echo).
-export([run/1]).
run(URL) ->
{ok, Conn} = hackney:wt_connect(URL),
ok = hackney:wt_send(Conn, {binary, <<"hello">>}),
{ok, {binary, Reply}} = hackney:wt_recv(Conn, 5000),
io:format("echo: ~s~n", [Reply]),
hackney:wt_close(Conn).
```
## Next Steps
- [WebSocket Guide](websocket_guide.md)
- [HTTP/3 Guide](http3_guide.md)
- [Getting Started](../GETTING_STARTED.md)
```