Skip to content

Storing Data Permanently on Arweave

A beginner's guide to the permaweb with HyperBEAM


What You'll Learn

By the end of this tutorial, you'll understand:

  1. Wallets — Your identity on Arweave
  2. Data Items — Content you want to store permanently
  3. Bundles — Efficient packaging for multiple items
  4. Uploading — How to send data to the permaweb
  5. How these pieces connect to form the permaweb

No prior Arweave knowledge required. Basic Erlang helps, but we'll explain as we go.


The Big Picture

Arweave is a permanent information storage protocol. Its core data structure is called a blockweave—each block links not only to the previous block (like a blockchain) but also to a random historical "recall block." This weaving pattern economically incentivizes miners to store all data, since they need access to random historical blocks to mine new ones.

When you store data on Arweave, you pay once for permanent storage. The protocol uses a storage endowment that pays miners over time, leveraging the declining cost of storage technology to make permanence economically sustainable.

Here's the mental model:

You (Wallet) → Sign → Data Item → Bundle → Arweave Network
     ↓                    ↓           ↓
  Identity            Content     Package

Think of it like sending a certified letter:

  • Wallet = Your signature and return address
  • Data Item = The letter you're sending
  • Bundle = An envelope containing multiple letters
  • Signing = Proving you wrote it

Let's build each piece.


Part 1: Your Wallet

📖 Reference: ar_wallet

A wallet is your identity. It contains:

  • A private key (secret — used to sign)
  • A public key (shared — used to verify)
  • An address (your unique identifier, derived from public key)

Creating a Wallet

%% Create a new wallet
Wallet = ar_wallet:new().

That's it. You now have a wallet. The wallet contains both your private key (for signing) and public key (for verification).

Getting Your Address

Your address is a 32-byte identifier derived from your public key:

Address = ar_wallet:to_address(Wallet).
%% Address is a 32-byte binary
 
%% To see it as a readable base64url string:
hb_util:encode(Address).
%% => <<"abc123...">>

Saving Your Wallet

Never lose your wallet! Save it to a file:

%% Export to JSON format (JWK)
JSON = ar_wallet:to_json(Wallet),
file:write_file("my-wallet.json", JSON).

Loading Your Wallet

{ok, JSON} = file:read_file("my-wallet.json"),
Wallet = ar_wallet:from_json(JSON).

Quick Reference: Wallet Functions

FunctionWhat it does
ar_wallet:new()Create new wallet
ar_wallet:to_address(Wallet)Get 32-byte address
ar_wallet:to_json(Wallet)Export to JSON (JWK)
ar_wallet:from_json(JSON)Import from JSON

Part 2: Data Items

📖 Reference: ar_bundles

A data item is a piece of content you want to store. It has:

  • Data — The actual content (text, JSON, image bytes, anything)
  • Tags — Metadata key-value pairs (like file headers)
  • Signature — Proof that you created it
  • ID — Unique identifier (calculated from content)

Creating a Data Item

Item = ar_bundles:new_item(
    <<>>,                                      % Target (usually empty)
    <<>>,                                      % Anchor (usually empty)
    [{<<"Content-Type">>, <<"text/plain">>}],  % Tags
    <<"Hello, Arweave!">>                      % Your data
).

The four parameters:

  1. Target — Recipient address (leave empty <<>> for data storage)
  2. Anchor — For ordering (leave empty <<>> for now)
  3. Tags — List of {Name, Value} pairs
  4. Data — Your content as binary

Signing a Data Item

An unsigned item is just a draft. Signing makes it official:

%% You need your wallet
Wallet = ar_wallet:new(),
 
%% Create and sign
Item = ar_bundles:new_item(<<>>, <<>>, [], <<"Hello!">>),
SignedItem = ar_bundles:sign_item(Item, Wallet).

After signing, the item has:

  • A signature (cryptographic proof you created it)
  • An ID (unique hash of the content)
  • An owner (your public key)

Verifying a Data Item

Anyone can verify that a signed item is authentic:

true = ar_bundles:verify_item(SignedItem).

Returns true if valid, false if tampered with.

Getting the Item ID

Every signed item has a unique ID:

ID = ar_bundles:id(SignedItem).
%% ID is a 32-byte binary
 
%% To see it as a readable base64url string:
hb_util:encode(ID).
%% => <<"47oNEhBtGP2A...">>

Who Signed This?

SignerAddress = ar_bundles:signer(SignedItem).
%% Returns the 32-byte address of whoever signed it

Complete Example

%% 1. Create wallet
Wallet = ar_wallet:new(),
 
%% 2. Create data item with tags
Item = ar_bundles:new_item(
    <<>>,
    <<>>,
    [
        {<<"Content-Type">>, <<"application/json">>},
        {<<"App-Name">>, <<"My First App">>},
        {<<"Version">>, <<"1.0">>}
    ],
    <<"{\"message\": \"Hello, World!\"}">>
),
 
%% 3. Sign it
SignedItem = ar_bundles:sign_item(Item, Wallet),
 
%% 4. Verify it worked
true = ar_bundles:verify_item(SignedItem),
 
%% 5. Get the ID
ID = ar_bundles:id(SignedItem).
%% ID is a 32-byte binary

Quick Reference: Data Item Functions

FunctionWhat it does
ar_bundles:new_item(Target, Anchor, Tags, Data)Create unsigned item
ar_bundles:sign_item(Item, Wallet)Sign an item
ar_bundles:verify_item(Item)Check if signature is valid
ar_bundles:id(Item)Get unique ID
ar_bundles:signer(Item)Get signer's address
ar_bundles:is_signed(Item)Check if item is signed

Part 3: Serialization

📖 Reference: ar_bundles

To store or transmit data items, you need to convert them to binary format and back.

Serialize (Item → Binary)

Binary = ar_bundles:serialize(SignedItem).
%% Now you can save to file, send over network, etc.

Deserialize (Binary → Item)

RecoveredItem = ar_bundles:deserialize(Binary).
 
%% It's still valid!
true = ar_bundles:verify_item(RecoveredItem).

Save to File

%% Save
Binary = ar_bundles:serialize(SignedItem),
file:write_file("my-item.bin", Binary).
 
%% Load
{ok, Binary} = file:read_file("my-item.bin"),
Item = ar_bundles:deserialize(Binary).

Part 4: Bundles

📖 Reference: ar_bundles

A bundle packages multiple data items together following the ANS-104 standard (Bundled Data v2.0). Why bundle?

  • Delegate payment — A 3rd party pays while you keep your signature
  • Scalability — Bundles can nest recursively (bundles within bundles)
  • Throughput — Increases writes to the network dramatically
  • Cost — One base layer transaction for many data items

ANS-104 is the foundation of AO (Arweave's compute layer) — every AO message is an ANS-104 DataItem.

Creating a Bundle

Wallet = ar_wallet:new(),
 
%% Create several items
Item1 = ar_bundles:sign_item(
    ar_bundles:new_item(<<>>, <<>>, 
        [{<<"Content-Type">>, <<"text/html">>}],
        <<"<h1>Hello</h1>">>),
    Wallet
),
 
Item2 = ar_bundles:sign_item(
    ar_bundles:new_item(<<>>, <<>>, 
        [{<<"Content-Type">>, <<"text/css">>}],
        <<"h1 { color: blue; }">>),
    Wallet
),
 
Item3 = ar_bundles:sign_item(
    ar_bundles:new_item(<<>>, <<>>, 
        [{<<"Content-Type">>, <<"application/javascript">>}],
        <<"console.log('Hi!');">>),
    Wallet
),
 
%% Bundle them together
Bundle = ar_bundles:serialize([Item1, Item2, Item3]).

Reading a Bundle

%% Deserialize the bundle
BundleItem = ar_bundles:deserialize(Bundle),
 
%% Get first item
FirstItem = ar_bundles:hd(BundleItem),
 
%% Get all items as a map
AllItems = ar_bundles:map(BundleItem),
%% Keys are <<"1">>, <<"2">>, <<"3">>
 
%% Access specific item
SecondItem = maps:get(<<"2">>, AllItems).

Searching in Bundles

%% Check if an item exists
true = ar_bundles:member(<<"1">>, BundleItem),
 
%% Find item by key
FoundItem = ar_bundles:find(<<"2">>, BundleItem).

Quick Reference: Bundle Functions

FunctionWhat it does
ar_bundles:serialize(Items)Convert to binary
ar_bundles:deserialize(Binary)Parse from binary
ar_bundles:hd(Bundle)Get first item
ar_bundles:map(Bundle)Get all items as map
ar_bundles:find(Key, Bundle)Find item by key
ar_bundles:member(Key, Bundle)Check if key exists

Part 5: Putting It All Together

📖 Reference: ar_wallet | ar_bundles

Create a test file at src/test/test_ar.erl:

-module(test_ar).
-include_lib("eunit/include/eunit.hrl").
-include("include/hb.hrl").
 
%% Run with: rebar3 eunit --module=test_ar
 
wallet_test() ->
    %% Create a new wallet
    Wallet = ar_wallet:new(),
    Address = ar_wallet:to_address(Wallet),
    ?debugFmt("Wallet address: ~s", [hb_util:encode(Address)]),
    
    %% Wallet should produce 32-byte address
    ?assertEqual(32, byte_size(Address)),
    
    %% Save and load wallet
    JSON = ar_wallet:to_json(Wallet),
    LoadedWallet = ar_wallet:from_json(JSON),
    ?assertEqual(Address, ar_wallet:to_address(LoadedWallet)).
 
data_item_test() ->
    Wallet = ar_wallet:new(),
    
    %% Create and sign a data item
    Item = ar_bundles:new_item(<<>>, <<>>, [
        {<<"Content-Type">>, <<"text/plain">>},
        {<<"App-Name">>, <<"test">>}
    ], <<"Hello, Arweave!">>),
    
    SignedItem = ar_bundles:sign_item(Item, Wallet),
    
    %% Verify the signature
    ?assert(ar_bundles:is_signed(SignedItem)),
    ?assert(ar_bundles:verify_item(SignedItem)),
    
    %% Get ID and signer
    ID = ar_bundles:id(SignedItem),
    Signer = ar_bundles:signer(SignedItem),
    ?debugFmt("Item ID: ~s", [hb_util:encode(ID)]),
    ?debugFmt("Signer: ~s", [hb_util:encode(Signer)]),
    
    ?assertEqual(32, byte_size(ID)),
    ?assertEqual(ar_wallet:to_address(Wallet), Signer).
 
serialization_test() ->
    Wallet = ar_wallet:new(),
    
    Item = ar_bundles:sign_item(
        ar_bundles:new_item(<<>>, <<>>, [], <<"test data">>),
        Wallet
    ),
    
    %% Serialize to binary
    Binary = ar_bundles:serialize(Item),
    ?debugFmt("Serialized size: ~p bytes", [byte_size(Binary)]),
    
    %% Deserialize and verify
    Recovered = ar_bundles:deserialize(Binary),
    ?assert(ar_bundles:verify_item(Recovered)),
    ?assertEqual(<<"test data">>, Recovered#tx.data).
 
bundle_test() ->
    Wallet = ar_wallet:new(),
    
    %% Create multiple items
    Item1 = ar_bundles:sign_item(
        ar_bundles:new_item(<<>>, <<>>, [
            {<<"Content-Type">>, <<"text/html">>}
        ], <<"<h1>Hello</h1>">>),
        Wallet
    ),
    
    Item2 = ar_bundles:sign_item(
        ar_bundles:new_item(<<>>, <<>>, [
            {<<"Content-Type">>, <<"application/json">>}
        ], <<"{\"key\": \"value\"}">>),
        Wallet
    ),
    
    %% Bundle them
    Bundle = ar_bundles:serialize([Item1, Item2]),
    ?debugFmt("Bundle size: ~p bytes", [byte_size(Bundle)]),
    
    %% Read back
    BundleItem = ar_bundles:deserialize(Bundle),
    
    %% Navigate the bundle
    First = ar_bundles:hd(BundleItem),
    ?assertEqual(<<"<h1>Hello</h1>">>, First#tx.data),
    
    %% Check membership
    Map = ar_bundles:map(BundleItem),
    ?assertEqual(2, maps:size(Map)),
    ?assert(ar_bundles:member(<<"1">>, BundleItem)),
    ?assert(ar_bundles:member(<<"2">>, BundleItem)).
 
website_bundle_test() ->
    Wallet = ar_wallet:new(),
    ?debugFmt("Creating website bundle...", []),
    
    %% HTML page
    Html = ar_bundles:sign_item(
        ar_bundles:new_item(<<>>, <<>>, [
            {<<"Content-Type">>, <<"text/html">>},
            {<<"filename">>, <<"index.html">>}
        ], <<"<!DOCTYPE html><html><body><h1>Welcome!</h1></body></html>">>),
        Wallet
    ),
    
    %% JSON data
    Data = ar_bundles:sign_item(
        ar_bundles:new_item(<<>>, <<>>, [
            {<<"Content-Type">>, <<"application/json">>},
            {<<"filename">>, <<"data.json">>}
        ], <<"{\"created\": \"2024\"}">>),
        Wallet
    ),
    
    %% Bundle and save
    Bundle = ar_bundles:serialize([Html, Data]),
    ?debugFmt("Bundle size: ~p bytes", [byte_size(Bundle)]),
    
    %% Verify roundtrip
    BundleItem = ar_bundles:deserialize(Bundle),
    FirstItem = ar_bundles:hd(BundleItem),
    ContentType = proplists:get_value(<<"Content-Type">>, FirstItem#tx.tags),
    ?assertEqual(<<"text/html">>, ContentType),
    ?debugFmt("First item Content-Type: ~s", [ContentType]).

Run the tests:

rebar3 eunit --module=test_ar

Part 6: Uploading to Arweave

Once you have a signed data item, you can upload it to Arweave via a bundler service. The bundler accepts signed ANS-104 data items via a simple HTTP POST.

Add this to your src/test/test_ar.erl:

%% Upload test - actually uploads to Arweave!
%% Run with: rebar3 eunit --module=test_ar --test=upload_test
upload_test() ->
    %% Create and sign a data item
    Wallet = ar_wallet:new(),
    Item = ar_bundles:sign_item(
        ar_bundles:new_item(<<>>, <<>>, [
            {<<"Content-Type">>, <<"text/plain">>}
        ], <<"Hello from HyperBEAM!">>),
        Wallet
    ),
    
    %% Serialize
    Binary = ar_bundles:serialize(Item),
    ?debugFmt("Uploading ~p bytes...", [byte_size(Binary)]),
    
    %% Upload
    inets:start(),
    ssl:start(),
    
    case httpc:request(post, {
        "https://up.arweave.net/tx",
        [],
        "application/octet-stream",
        Binary
    }, [], []) of
        {ok, {{_, 200, _}, _, Response}} ->
            ?debugFmt("Response: ~s", [Response]),
            
            %% Parse the ID from response
            #{<<"id">> := ID} = hb_json:decode(list_to_binary(Response)),
            ?debugFmt("Uploaded! View at: https://arweave.net/~s", [ID]),
            
            ?assert(is_binary(ID));
        {ok, {{_, Status, _}, _, Body}} ->
            ?debugFmt("Error ~p: ~s", [Status, Body]),
            ?assert(false);
        {error, Reason} ->
            ?debugFmt("Request failed: ~p", [Reason]),
            ?assert(false)
    end.

Response Format

The bundler returns JSON with your transaction details:

{
  "id": "47oNEhBtGP2Alimt8tdSJ9RtSb2qaPLe3ye7i--jyg8",
  "timestamp": 1766369047433,
  "winc": "0",
  "dataCaches": ["arweave.net"],
  "fastFinalityIndexes": ["arweave.net"],
  ...
}

Your data is now permanently accessible at:

https://arweave.net/{id}

For example: https://arweave.net/47oNEhBtGP2Alimt8tdSJ9RtSb2qaPLe3ye7i--jyg8

Note: The bundler service (up.arweave.net) handles small uploads for free. Your data will be permanently stored on Arweave.


Common Patterns

Pattern 1: Create → Sign → Serialize

Wallet = ar_wallet:new(),
 
SignedItem = ar_bundles:sign_item(
    ar_bundles:new_item(<<>>, <<>>, Tags, Data),
    Wallet
),
 
Binary = ar_bundles:serialize(SignedItem).

Pattern 2: Deserialize → Verify → Use

Item = ar_bundles:deserialize(Binary),
 
case ar_bundles:verify_item(Item) of
    true -> 
        %% Safe to use
        process(Item#tx.data);
    false -> 
        error(invalid_signature)
end.

Pattern 3: Tagged Content

%% Always tag your content for discoverability
Tags = [
    {<<"Content-Type">>, <<"application/json">>},  % What format
    {<<"App-Name">>, <<"MyApp">>},                 % Your app
    {<<"App-Version">>, <<"1.0.0">>},              % Version
    {<<"Type">>, <<"user-profile">>}               % Content type
],
 
Item = ar_bundles:new_item(<<>>, <<>>, Tags, JsonData).

What's Next?

You now understand the core concepts:

ConceptModuleKey Functions
Walletar_walletnew, to_address, to_json, from_json
Data Itemsar_bundlesnew_item, sign_item, verify_item, id, signer
Serializationar_bundlesserialize, deserialize
Bundlesar_bundleshd, map, find, member
UploadinghttpcPOST to up.arweave.net

Going Further

  1. The Permaweb — Applications and websites stored permanently, accessible via normal browsers
  2. Build with HyperBEAM — These primitives power all HyperBEAM devices (Book)
  3. Explore AO — Arweave's compute layer where every message is an ANS-104 DataItem

Quick Reference Card

📖 Reference: ar_wallet | ar_bundles

%% === WALLET ===
Wallet = ar_wallet:new().
Address = ar_wallet:to_address(Wallet).
JSON = ar_wallet:to_json(Wallet).
Wallet = ar_wallet:from_json(JSON).
 
%% === DATA ITEM ===
Item = ar_bundles:new_item(<<>>, <<>>, Tags, Data).
Signed = ar_bundles:sign_item(Item, Wallet).
true = ar_bundles:verify_item(Signed).
ID = ar_bundles:id(Signed).
Addr = ar_bundles:signer(Signed).
 
%% === SERIALIZE ===
Binary = ar_bundles:serialize(Signed).
Item = ar_bundles:deserialize(Binary).
 
%% === BUNDLE ===
Bundle = ar_bundles:serialize([Item1, Item2, Item3]).
BundleItem = ar_bundles:deserialize(Bundle).
First = ar_bundles:hd(BundleItem).
Map = ar_bundles:map(BundleItem).
Found = ar_bundles:find(Key, BundleItem).
true = ar_bundles:member(Key, BundleItem).
 
%% === UPLOAD ===
inets:start(), ssl:start().
{ok, {{_, 200, _}, _, Resp}} = httpc:request(post, {
    "https://up.arweave.net/tx", [],
    "application/octet-stream", Binary
}, [], []).

Now go build something permanent!


Resources

HyperBEAM Documentation Protocol Documentation Standards (ANS) Ecosystem