Skip to content

L3: Authenticated API Gateway

Build an advanced device with authentication, payment processing, task scheduling, and request routing.

What You'll Build

A full-featured API gateway with these endpoints:

POST /~gateway@1.0/register_key       Register API key (requires signature)
POST /~gateway@1.0/authenticate       Authenticate with API key
GET  /~gateway@1.0/balance            Check token balance
POST /~gateway@1.0/deposit            Deposit tokens
POST /~gateway@1.0/withdraw           Withdraw tokens
POST /~gateway@1.0/api_call           Make API call (costs tokens)
POST /~gateway@1.0/schedule           Schedule recurring task
GET  /~gateway@1.0/tasks              List scheduled tasks
POST /~gateway@1.0/cancel_task        Cancel a task
POST /~gateway@1.0/add_route          Add routing rule
GET  /~gateway@1.0/routes             List routing rules

What You'll Learn

ConceptPurpose
Message signaturesVerify request authenticity
API key managementStateless authentication
Token balancesPay-per-use API access
Task schedulingCron-based recurring tasks
Request routingURL pattern matching

Prerequisites


Part 1: Device Structure

Create HyperBEAM/src/dev_gateway.erl:

%%%-------------------------------------------------------------------
%%% @doc Authenticated API Gateway Device
%%%
%%% Full-featured gateway with auth, payment, scheduling, routing.
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(dev_gateway).
-export([
    info/3,
    %% Auth
    register_key/3, authenticate/3,
    %% Payment
    balance/3, deposit/3, withdraw/3,
    %% API
    api_call/3,
    %% Scheduling
    schedule/3, tasks/3, cancel_task/3,
    %% Routing
    add_route/3, routes/3
]).
-include("include/hb.hrl").
 
-define(KEYS_KEY, <<"gateway-keys">>).
-define(BALANCES_KEY, <<"gateway-balances">>).
-define(TASKS_KEY, <<"gateway-tasks">>).
-define(ROUTES_KEY, <<"gateway-routes">>).
-define(API_COST, 10).

Storage Pattern

This device stores all state in the <<"priv">> map directly (no cache). This works well for in-memory state that doesn't need to persist across restarts.

%% Load data from priv map
load_data(M1, Key, _Opts) ->
    case maps:get(<<"priv">>, M1, #{}) of
        #{Key := Data} when is_map(Data) -> Data;
        _ -> #{}
    end.
 
%% Save data to priv map
save_data(M1, Key, Data, _Opts) ->
    Priv = maps:get(<<"priv">>, M1, #{}),
    M1#{<<"priv">> => Priv#{Key => Data}}.

Part 2: Device Info

%%====================================================================
%% Device Info
%%====================================================================
 
info(_M1, _M2, _Opts) ->
    {ok, #{
        <<"name">> => <<"gateway">>,
        <<"version">> => <<"1.0">>,
        <<"description">> => <<"Authenticated API Gateway">>,
        <<"features">> => [
            <<"api-key-auth">>,
            <<"token-payment">>,
            <<"task-scheduling">>,
            <<"request-routing">>
        ]
    }}.

Part 3: Authentication

Getting Message Signers

Every signed message contains a list of signers (wallet addresses). Use hb_message:signers/2 to extract them:

get_signer(M2, Opts) ->
    case hb_message:signers(M2, Opts) of
        [] -> not_found;
        [Signer | _] -> Signer
    end.

Register API Key

Users register API keys by sending a signed request. The signer's address is linked to the generated key.

%%====================================================================
%% Authentication
%%====================================================================
 
register_key(M1, M2, Opts) ->
    case get_signer(M2, Opts) of
        not_found ->
            {error, #{<<"status">> => 401, <<"error">> => <<"Request not signed">>}};
        Signer ->
            APIKey = generate_api_key(),
            Keys = load_data(M1, ?KEYS_KEY, Opts),
            NewKeys = maps:put(APIKey, Signer, Keys),
            M1Updated = save_data(M1, ?KEYS_KEY, NewKeys, Opts),
            {ok, maps:merge(M1Updated, #{
                <<"api_key">> => APIKey,
                <<"address">> => Signer
            })}
    end.
 
generate_api_key() ->
    hb_util:encode(crypto:strong_rand_bytes(32)).

Authenticate

Validate an API key and return the associated address:

authenticate(M1, M2, Opts) ->
    case maps:get(<<"api-key">>, M2, not_found) of
        not_found ->
            {error, #{<<"status">> => 401, <<"error">> => <<"Missing API key">>}};
        Key ->
            Keys = load_data(M1, ?KEYS_KEY, Opts),
            case maps:get(Key, Keys, not_found) of
                not_found ->
                    {error, #{<<"status">> => 401, <<"error">> => <<"Invalid">>}};
                Address ->
                    {ok, #{<<"authenticated">> => true, <<"address">> => Address}}
            end
    end.

Unified Authentication Helper

Support both API key and signature-based authentication:

get_authenticated_address(M1, M2, Opts) ->
    case maps:get(<<"api-key">>, M2, not_found) of
        not_found ->
            case get_signer(M2, Opts) of
                not_found -> {error, #{<<"status">> => 401, <<"error">> => <<"Not authenticated">>}};
                Signer -> Signer
            end;
        APIKey ->
            Keys = load_data(M1, ?KEYS_KEY, Opts),
            case maps:get(APIKey, Keys, not_found) of
                not_found -> {error, #{<<"status">> => 401, <<"error">> => <<"Invalid API key">>}};
                Address -> Address
            end
    end.

Part 4: Payment Processing

Check Balance

%%====================================================================
%% Payment
%%====================================================================
 
balance(M1, M2, Opts) ->
    case get_authenticated_address(M1, M2, Opts) of
        {error, E} -> {error, E};
        Addr ->
            Balances = load_data(M1, ?BALANCES_KEY, Opts),
            {ok, #{<<"address">> => Addr, <<"balance">> => maps:get(Addr, Balances, 0)}}
    end.

Deposit

deposit(M1, M2, Opts) ->
    case get_authenticated_address(M1, M2, Opts) of
        {error, E} -> {error, E};
        Addr ->
            Amount = maps:get(<<"amount">>, M2, 0),
            case Amount > 0 of
                false ->
                    {error, #{<<"status">> => 400, <<"error">> => <<"Invalid amount">>}};
                true ->
                    Balances = load_data(M1, ?BALANCES_KEY, Opts),
                    NewBalance = maps:get(Addr, Balances, 0) + Amount,
                    NewBalances = maps:put(Addr, NewBalance, Balances),
                    M1Updated = save_data(M1, ?BALANCES_KEY, NewBalances, Opts),
                    {ok, maps:merge(M1Updated, #{
                        <<"deposited">> => Amount,
                        <<"balance">> => NewBalance
                    })}
            end
    end.

Withdraw

withdraw(M1, M2, Opts) ->
    case get_authenticated_address(M1, M2, Opts) of
        {error, E} -> {error, E};
        Addr ->
            Amount = maps:get(<<"amount">>, M2, 0),
            Balances = load_data(M1, ?BALANCES_KEY, Opts),
            CurrentBalance = maps:get(Addr, Balances, 0),
            case {Amount > 0, CurrentBalance >= Amount} of
                {false, _} ->
                    {error, #{<<"status">> => 400, <<"error">> => <<"Invalid amount">>}};
                {_, false} ->
                    {error, #{<<"status">> => 402, <<"error">> => <<"Insufficient balance">>}};
                {true, true} ->
                    NewBalance = CurrentBalance - Amount,
                    NewBalances = maps:put(Addr, NewBalance, Balances),
                    M1Updated = save_data(M1, ?BALANCES_KEY, NewBalances, Opts),
                    {ok, maps:merge(M1Updated, #{
                        <<"withdrawn">> => Amount,
                        <<"balance">> => NewBalance
                    })}
            end
    end.

Part 5: Protected API

API calls cost tokens. Check balance before processing, then deduct:

%%====================================================================
%% Protected API
%%====================================================================
 
api_call(M1, M2, Opts) ->
    case get_authenticated_address(M1, M2, Opts) of
        {error, E} -> {error, E};
        Address ->
            Balances = load_data(M1, ?BALANCES_KEY, Opts),
            Balance = maps:get(Address, Balances, 0),
            case Balance >= ?API_COST of
                false ->
                    {error, #{
                        <<"status">> => 402,
                        <<"error">> => <<"Insufficient balance">>,
                        <<"required">> => ?API_COST,
                        <<"balance">> => Balance
                    }};
                true ->
                    Action = maps:get(<<"action">>, M2, <<"default">>),
                    Data = maps:get(<<"data">>, M2, #{}),
                    Result = #{
                        <<"action">> => Action,
                        <<"processed">> => true,
                        <<"timestamp">> => erlang:system_time(second),
                        <<"input">> => Data
                    },
                    NewBalance = Balance - ?API_COST,
                    NewBalances = maps:put(Address, NewBalance, Balances),
                    M1Updated = save_data(M1, ?BALANCES_KEY, NewBalances, Opts),
                    {ok, maps:merge(M1Updated, #{
                        <<"result">> => Result,
                        <<"charged">> => ?API_COST,
                        <<"balance">> => NewBalance
                    })}
            end
    end.

Part 6: Task Scheduling

Schedule a Task

Create recurring tasks with cron expressions:

%%====================================================================
%% Scheduling
%%====================================================================
 
schedule(M1, M2, Opts) ->
    case get_authenticated_address(M1, M2, Opts) of
        {error, E} -> {error, E};
        Addr ->
            Cron = maps:get(<<"cron">>, M2, <<"* * * * *">>),
            Action = maps:get(<<"action">>, M2, not_found),
            Data = maps:get(<<"data">>, M2, #{}),
            case Action of
                not_found ->
                    {error, #{<<"status">> => 400, <<"error">> => <<"Missing action">>}};
                _ ->
                    TaskID = generate_task_id(),
                    Task = #{
                        <<"id">> => TaskID, <<"owner">> => Addr,
                        <<"cron">> => Cron, <<"action">> => Action,
                        <<"data">> => Data, <<"created">> => erlang:system_time(second),
                        <<"status">> => <<"active">>
                    },
                    Tasks = load_data(M1, ?TASKS_KEY, Opts),
                    M1Updated = save_data(M1, ?TASKS_KEY, maps:put(TaskID, Task, Tasks), Opts),
                    {ok, maps:merge(M1Updated, #{
                        <<"task_id">> => TaskID,
                        <<"cron">> => Cron,
                        <<"status">> => <<"scheduled">>
                    })}
            end
    end.
 
generate_task_id() ->
    hb_util:encode(crypto:strong_rand_bytes(16)).

List Tasks

Filter tasks by owner:

tasks(M1, M2, Opts) ->
    case get_authenticated_address(M1, M2, Opts) of
        {error, E} -> {error, E};
        Addr ->
            AllTasks = load_data(M1, ?TASKS_KEY, Opts),
            UserTasks = maps:filter(fun(_K, V) -> maps:get(<<"owner">>, V) =:= Addr end, AllTasks),
            {ok, #{<<"tasks">> => maps:values(UserTasks), <<"count">> => maps:size(UserTasks)}}
    end.

Cancel a Task

Only the owner can cancel their own tasks:

cancel_task(M1, M2, Opts) ->
    case {get_authenticated_address(M1, M2, Opts), maps:get(<<"id">>, M2, not_found)} of
        {{error, E}, _} -> {error, E};
        {_, not_found} ->
            {error, #{<<"status">> => 400, <<"error">> => <<"Missing task ID">>}};
        {Addr, TID} ->
            Tasks = load_data(M1, ?TASKS_KEY, Opts),
            case maps:get(TID, Tasks, not_found) of
                not_found ->
                    {error, #{<<"status">> => 404, <<"error">> => <<"Not found">>}};
                Task ->
                    case maps:get(<<"owner">>, Task) of
                        Addr ->
                            UpdatedTask = Task#{<<"status">> => <<"cancelled">>},
                            M1Updated = save_data(M1, ?TASKS_KEY, maps:put(TID, UpdatedTask, Tasks), Opts),
                            {ok, maps:merge(M1Updated, #{<<"task_id">> => TID, <<"status">> => <<"cancelled">>})};
                        _ ->
                            {error, #{<<"status">> => 403, <<"error">> => <<"Forbidden">>}}
                    end
            end
    end.

Part 7: Request Routing

Add a Route

%%====================================================================
%% Routing
%%====================================================================
 
add_route(M1, M2, Opts) ->
    case {maps:get(<<"template">>, M2, not_found), maps:get(<<"node">>, M2, not_found)} of
        {not_found, _} ->
            {error, #{<<"status">> => 400, <<"error">> => <<"Missing template">>}};
        {_, not_found} ->
            {error, #{<<"status">> => 400, <<"error">> => <<"Missing node">>}};
        {T, N} ->
            Route = #{
                <<"template">> => T, <<"node">> => N,
                <<"methods">> => maps:get(<<"methods">>, M2, [<<"GET">>, <<"POST">>]),
                <<"created">> => erlang:system_time(second)
            },
            Routes = load_data(M1, ?ROUTES_KEY, Opts),
            M1Updated = save_data(M1, ?ROUTES_KEY, maps:put(T, Route, Routes), Opts),
            {ok, maps:merge(M1Updated, #{<<"route">> => Route, <<"status">> => <<"added">>})}
    end.

List Routes

routes(M1, _M2, Opts) ->
    Routes = load_data(M1, ?ROUTES_KEY, Opts),
    {ok, #{<<"routes">> => maps:values(Routes), <<"count">> => maps:size(Routes)}}.

Part 8: Testing

Test the device using hb_ao:resolve with the {as, Module, Msg} pattern.

Test Setup

-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
 
setup_test_env() ->
    application:ensure_all_started(hb),
    Store = hb_test_utils:test_store(hb_store_fs),
    Wallet = ar_wallet:new(),
    #{store => [Store], priv_wallet => Wallet}.

Test Device Info

%% Test device info via hb_ao:resolve
info_test() ->
    application:ensure_all_started(hb),
    {ok, Info} = hb_ao:resolve(
        {as, dev_gateway, #{}},
        #{<<"path">> => <<"info">>},
        #{}
    ),
    ?assertEqual(<<"gateway">>, maps:get(<<"name">>, Info)).

Test Payment Flow

%% Test payment flow via hb_ao:resolve
payment_flow_test() ->
    Opts = setup_test_env(),
    Wallet = maps:get(priv_wallet, Opts),
    M1 = #{},
 
    %% Sign a message with amount
    SignedMsg = hb_message:commit(#{<<"amount">> => 1000}, #{priv_wallet => Wallet}),
 
    %% Deposit via hb_ao:resolve
    {ok, DepRes} = hb_ao:resolve(
        {as, dev_gateway, M1},
        maps:merge(SignedMsg, #{<<"path">> => <<"deposit">>}),
        Opts
    ),
    ?assertEqual(1000, maps:get(<<"balance">>, DepRes)),
 
    %% Check balance
    {ok, BalRes} = hb_ao:resolve(
        {as, dev_gateway, DepRes},
        maps:merge(SignedMsg, #{<<"path">> => <<"balance">>}),
        Opts
    ),
    ?assertEqual(1000, maps:get(<<"balance">>, BalRes)),
 
    %% Withdraw
    WithdrawMsg = hb_message:commit(#{<<"amount">> => 500}, #{priv_wallet => Wallet}),
    {ok, WithRes} = hb_ao:resolve(
        {as, dev_gateway, DepRes},
        maps:merge(WithdrawMsg, #{<<"path">> => <<"withdraw">>}),
        Opts
    ),
    ?assertEqual(500, maps:get(<<"balance">>, WithRes)).

Test API Call

%% Test API call via hb_ao:resolve
api_call_test() ->
    Opts = setup_test_env(),
    Wallet = maps:get(priv_wallet, Opts),
    M1 = #{},
 
    %% Deposit first
    SignedMsg = hb_message:commit(#{<<"amount">> => 100}, #{priv_wallet => Wallet}),
    {ok, DepRes} = hb_ao:resolve(
        {as, dev_gateway, M1},
        maps:merge(SignedMsg, #{<<"path">> => <<"deposit">>}),
        Opts
    ),
 
    %% Make API call
    APICallMsg = hb_message:commit(#{
        <<"action">> => <<"test">>,
        <<"data">> => #{<<"foo">> => <<"bar">>}
    }, #{priv_wallet => Wallet}),
    {ok, APIRes} = hb_ao:resolve(
        {as, dev_gateway, DepRes},
        maps:merge(APICallMsg, #{<<"path">> => <<"api_call">>}),
        Opts
    ),
    ?assertEqual(10, maps:get(<<"charged">>, APIRes)),
    ?assertEqual(90, maps:get(<<"balance">>, APIRes)).

Test Scheduling

%% Test scheduling via hb_ao:resolve
scheduling_test() ->
    Opts = setup_test_env(),
    Wallet = maps:get(priv_wallet, Opts),
    M1 = #{},
 
    %% Schedule a task
    SchedMsg = hb_message:commit(#{
        <<"action">> => <<"test-action">>,
        <<"cron">> => <<"0 * * * *">>,
        <<"data">> => #{<<"key">> => <<"value">>}
    }, #{priv_wallet => Wallet}),
    {ok, SchedRes} = hb_ao:resolve(
        {as, dev_gateway, M1},
        maps:merge(SchedMsg, #{<<"path">> => <<"schedule">>}),
        Opts
    ),
    ?assertEqual(<<"scheduled">>, maps:get(<<"status">>, SchedRes)),
 
    %% List tasks
    {ok, TasksRes} = hb_ao:resolve(
        {as, dev_gateway, SchedRes},
        maps:merge(SchedMsg, #{<<"path">> => <<"tasks">>}),
        Opts
    ),
    ?assertEqual(1, maps:get(<<"count">>, TasksRes)).

Test Routing

%% Test routing via hb_ao:resolve
routing_test() ->
    Opts = setup_test_env(),
    M1 = #{},
 
    %% Add route
    {ok, AddRes} = hb_ao:resolve(
        {as, dev_gateway, M1},
        #{
            <<"path">> => <<"add_route">>,
            <<"template">> => <<"/api/users/*">>,
            <<"node">> => #{<<"prefix">> => <<"http://backend:8080">>}
        },
        Opts
    ),
    ?assertEqual(<<"added">>, maps:get(<<"status">>, AddRes)),
 
    %% List routes
    {ok, RoutesRes} = hb_ao:resolve(
        {as, dev_gateway, AddRes},
        #{<<"path">> => <<"routes">>},
        Opts
    ),
    ?assertEqual(1, maps:get(<<"count">>, RoutesRes)).
 
-endif.

Run Tests

rebar3 eunit --module=dev_gateway

Part 8: Device Registration

Register the gateway device to use with the ~device@version URL syntax.

Add to sys.config

{hb, [
    {preloaded_devices, [
        %% ... existing devices ...
        #{name => <<"gateway@1.0">>, module => dev_gateway}
    ]}
]}

Or Register at Runtime

hb:init(#{
    preloaded_devices => [
        #{name => <<"gateway@1.0">>, module => dev_gateway}
    ]
}).

Verify Registration

GET  http://localhost:8734/~gateway@1.0/info
POST http://localhost:8734/~gateway@1.0/deposit?amount=100
POST http://localhost:8734/~gateway@1.0/call?endpoint=/api/data

Key Concepts

ConceptImplementation
Authenticationhb_message:signers/2 or API key lookup
Balance trackingIn-memory map in <<"priv">>
Cost enforcementCheck balance before API calls
OwnershipFilter by authenticated address
RoutingTemplate-to-node mapping

Next Steps


Resources