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:
- Wallets — Your identity on Arweave
- Data Items — Content you want to store permanently
- Bundles — Efficient packaging for multiple items
- Uploading — How to send data to the permaweb
- 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 PackageThink 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
| Function | What 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:
- Target — Recipient address (leave empty
<<>>for data storage) - Anchor — For ordering (leave empty
<<>>for now) - Tags — List of
{Name, Value}pairs - 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 itComplete 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 binaryQuick Reference: Data Item Functions
| Function | What 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
| Function | What 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_arPart 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:
| Concept | Module | Key Functions |
|---|---|---|
| Wallet | ar_wallet | new, to_address, to_json, from_json |
| Data Items | ar_bundles | new_item, sign_item, verify_item, id, signer |
| Serialization | ar_bundles | serialize, deserialize |
| Bundles | ar_bundles | hd, map, find, member |
| Uploading | httpc | POST to up.arweave.net |
Going Further
- The Permaweb — Applications and websites stored permanently, accessible via normal browsers
- Build with HyperBEAM — These primitives power all HyperBEAM devices (Book)
- 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- ar_wallet Reference — Wallet functions
- ar_bundles Reference — Data items and bundles
- Full Reference — All modules
- Arweave Lightpaper — Protocol overview
- Arweave Yellow Paper — Technical deep dive
- Arweave 2.6 Spec — Mining mechanism details
- Arweave Node — Reference implementation (Erlang)
- ANS-104 — Bundled Data v2.0 (what this tutorial covers)
- All Standards
- AO Cookbook — Build on Arweave's compute layer