Erlang inets application provides the httpc module to make REST calls. The syntax is basic at first glance, but 80% of newbies make the same mistakes: not starting applications, SSL handshake issues on OTP 25+, binary/string confusion and not handling timeouts. This article provides a production-worthy example with all of the above errors explained and resolved.
Overview
Erlang is not the first language that springs to mind when it comes to HTTP clients Python, Node.js and Go are the big three. But if you’re writing distributed systems, message brokers, or telecom systems in Erlang, you will need to make a request to a REST API: either to trigger a webhook, load a config, or connect to a third-party service.
The good news: Erlang comes with an HTTP client called httpc. The bad news: it’s finicky to get setup and the error messages can be very confusing. In this article we’ll show you how to make REST calls in Erlang properly, but more importantly, what to do when things go wrong.
The Basics A Simple GET Request
Here’s the minimal working example that gets passed around in tutorials:

erlang
-module(rest_client).
-export([get_data/0]).
get_data() ->
inets:start(),
ssl:start(),
Url = "https://jsonplaceholder.typicode.com/posts/1",
case httpc:request(get, {Url, []}, [], []) of
{ok, {{_Version, 200, _ReasonPhrase}, _Headers, Body}} ->
io:format("Response Body: ~s~n", [Body]);
{error, Reason} ->
io:format("Error: ~p~n", [Reason])
end.
This works the first time. Run it twice, and it breaks. Use it in production, and it leaks resources. Let’s break down why and fix it properly.
The Error Catalog Every Common Failure, Explain
Error 1: {error, {already_started, inets}}
What you see:
erlang
** exception error: no match of right hand side value {error,{already_started,inets}}
Why it happens: inets:start() is not idempotent. Calling it twice crashes if you pattern-match on ok.
The fix: Use application:ensure_all_started/1 instead. It’s safe to call repeatedly and starts dependencies automatically.
erlang
{ok, _} = application:ensure_all_started(inets),
{ok, _} = application:ensure_all_started(ssl).
Better yet: Start these once at application boot, not inside every request function.
Error 2: {tls_alert, "handshake failure"}
What you see:
erlang
{error,{failed_connect,
[{to_address,{"api.example.com",443}},
{inet,[inet],{tls_alert,{handshake_failure,"..."}}}]}}
Why it happens: Starting with OTP 25, httpc no longer silently accepts unverified TLS certificates. If you don’t pass SSL options with proper certificate authorities, the handshake fails.
The fix: Pass explicit SSL options:
erlang
SslOpts = [
{verify, verify_peer},
{cacerts, public_key:cacerts_get()},
{depth, 3},
{customize_hostname_check,
[{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]}
],
HttpOpts = [{ssl, SslOpts}].
public_key:cacerts_get() (available since OTP 25) loads the OS’s trusted root certificates automatically no need to bundle your own CA file.
Error 3: function_clause on Binary URLs
What you see:
erlang
** exception error: no function clause matching
httpc:request(get, {<<"https://...">>, []}, [], [])
Why it happens: httpc requires URLs as strings (lists of integers), not binaries. If you load your URL from a config file, environment variable, or JSON payload, it’s likely a binary.
The fix: Add a guard clause that converts binaries automatically:
erlang
get_data(Url) when is_binary(Url) ->
get_data(binary_to_list(Url));
get_data(Url) when is_list(Url) ->
%% actual logic here
...
Error 4: {error, timeout} – The Silent Killer
What you see: Your process hangs forever. No error. No response. Just silence.
Why it happens: Without explicit timeouts, httpc waits indefinitely on a slow or dead server.
The fix: Always set both timeout (full request) and connect_timeout (initial TCP connection):
erlang
HttpOpts = [
{ssl, SslOpts},
{timeout, 10000}, % 10s total
{connect_timeout, 5000} % 5s for connection
].
Error 5: Garbled UTF-8 in Response Bodies
What you see: Characters like é, ñ, 中文 show up as é or \\303\\251 in your output.
Why it happens: By default, httpc returns the body as a string (list of bytes), which doesn’t preserve multi-byte UTF-8 sequences cleanly when printed with ~s.
The fix: Request the body as a binary:
erlang
Opts = [{body_format, binary}].
httpc:request(get, Request, HttpOpts, Opts).
Then print with ~ts (translated string) instead of ~s. Bonus: binaries are also what JSON libraries like jsx and jsone expect.
Error 6: nxdomain and econnrefused
What you see:
erlang
{error,{failed_connect,[{to_address,{"api.foo.com",443}},{inet,[inet],nxdomain}]}}
{error,{failed_connect,[{to_address,{"localhost",8080}},{inet,[inet],econnrefused}]}}
Why it happens:
nxdomain– DNS lookup failed. The hostname doesn’t resolve.econnrefused– Server isn’t listening on that port.
The fix: These are environment problems, not code bugs. Handle them gracefully:
erlang
{error, {failed_connect, _}} = Err ->
log_network_failure(Err),
{error, network_unreachable}
Error 7: Forgetting to Handle Non-200 Status Codes
What you see: Your code “succeeds” but your data is empty or wrong. Pattern matching on 200 only handles the happy path; 404, 500, 401 fall through silently.
The fix: Match on status code explicitly:
erlang
case httpc:request(...) of
{ok, {{_, 200, _}, _, Body}} -> {ok, Body};
{ok, {{_, 404, _}, _, _}} -> {error, not_found};
{ok, {{_, 401, _}, _, _}} -> {error, unauthorized};
{ok, {{_, S, _}, _, _}} when S >= 500 -> {error, {server_error, S}};
{ok, {{_, S, _}, _, _}} -> {error, {http_status, S}};
{error, Reason} -> {error, Reason}
end.
The Production-Ready Implementation
Putting all the fixes together:
erlang
-module(rest_client).
-export([start_deps/0, get_data/0, get_data/1]).
%% Call this once at application boot not per request
start_deps() ->
{ok, _} = application:ensure_all_started(inets),
{ok, _} = application:ensure_all_started(ssl),
ok.
get_data() ->
get_data("https://jsonplaceholder.typicode.com/posts/1").
get_data(Url) when is_binary(Url) ->
get_data(binary_to_list(Url));
get_data(Url) when is_list(Url) ->
SslOpts = [
{verify, verify_peer},
{cacerts, public_key:cacerts_get()},
{depth, 3},
{customize_hostname_check,
[{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]}
],
HttpOpts = [
{ssl, SslOpts},
{timeout, 10000},
{connect_timeout, 5000}
],
Opts = [{body_format, binary}],
Headers = [
{"User-Agent", "erlang-httpc/1.0"},
{"Accept", "application/json"}
],
Request = {Url, Headers},
case httpc:request(get, Request, HttpOpts, Opts) of
{ok, {{_V, 200, _R}, _Hdrs, Body}} ->
{ok, Body};
{ok, {{_V, 404, _R}, _Hdrs, _Body}} ->
{error, not_found};
{ok, {{_V, 401, _R}, _Hdrs, _Body}} ->
{error, unauthorized};
{ok, {{_V, Status, _R}, _Hdrs, _Body}} when Status >= 500 ->
{error, {server_error, Status}};
{ok, {{_V, Status, _R}, _Hdrs, _Body}} ->
{error, {http_status, Status}};
{error, {failed_connect, _}} = Err ->
Err;
{error, timeout} ->
{error, timeout};
{error, Reason} ->
{error, Reason}
end.
Testing It
Fire up the Erlang shell:
erlang
1> c(rest_client).
{ok,rest_client}
2> rest_client:start_deps().
ok
3> rest_client:get_data().
{ok,<<"{\n \"userId\": 1,\n \"id\": 1,\n \"title\": \"sunt aut facere...\",\n \"body\": \"...\"\n}">>}
Parsing JSON Responses
httpc gives you raw bytes. To work with JSON, add jsx (or jsone) to your rebar.config:
erlang
{deps, [{jsx, "3.1.0"}]}.
Then decode the body:
erlang
{ok, Body} = rest_client:get_data(),
Decoded = jsx:decode(Body, [return_maps]).
%% => #{<<"userId">> => 1, <<"id">> => 1, <<"title">> => <<"sunt aut...">>, ...}
POST, PUT, DELETE – The Quick Reference
erlang
%% POST with JSON body
Body = jsx:encode(#{<<"name">> => <<"Alice">>, <<"age">> => 30}),
httpc:request(post,
{Url, Headers, "application/json", Body},
HttpOpts, Opts).
%% PUT
httpc:request(put,
{Url, Headers, "application/json", Body},
HttpOpts, Opts).
%% DELETE
httpc:request(delete, {Url, Headers}, HttpOpts, Opts).
When to Reach for Something Else
httpc is fine for:
- Scripts and one-off integrations
- Low-volume API calls (< 100 req/sec)
- Health checks and webhooks
- Bootstrapping when you don’t want extra dependencies
Switch to hackney or gun when you need:
- Connection pooling for high throughput.
- HTTP/2 support.
- WebSocket upgrades (Gun handles this natively).
- Streaming uploads/downloads.
- More ergonomic APIs.