OneEur.com
Developers

Build on the OneEur API.

A small JSON API over HTTPS. Read balances, chains and payments, and submit blocks your own code has signed. There are no API keys: every write is a block signed by the wallet itself.

Basics

Before you start.

Base URL
https://gateway.oneeur.com
Format
JSON in and out. Send content-type: application/json on POST. Request bodies are limited to 64 KB.
Auth
None. Reads are public. A block is accepted only if the wallet’s key signed it, so the API never needs your secret.
Amounts
Strings of whole units, so no language loses precision. 1 OEUR = 100000000 units.
Hashes
Lowercase hex: 64 characters for a hash, 128 for a signature.
Addresses
oe_ed25519_…: the public key plus a 4-byte checksum, in lowercase base32.
Browsers
The API sends no CORS headers, so a web page on another site can’t call it from the browser. Call it from a server or a mobile app.
Status
Prototype. Endpoints may change, there are no rate limits yet, and test networks may be reset.
Try it
curl https://gateway.oneeur.com/api/overview
{
  "genesis": "oe_ed25519_iawd2rvv…",
  "total_supply": "600000000000000000000000000",
  "block_count": 481510,
  "wallet_count": 23,
  "pending_total": "82721929340"
}

Every confirmed block is also visible in the explorer.

Open the explorer
Endpoints

Wallets and blocks.

GET /api/accounts/:address
A wallet’s balance, height and latest block hash (head). Read this before signing the wallet’s next block. 404 if the wallet has no chain yet.{address, balance, height, head}
GET /api/accounts/:address/pending
Payments sent to the wallet that it hasn’t collected yet.{address, pending: [{send_hash, from, amount, memo}]}
GET /api/accounts/:address/chain
The wallet’s own blocks, newest first. limit is 1–500, default 50.{address, blocks: [block]}
GET /api/blocks/:hash
One block by its hash.block
POST /api/blocks
Submit a signed block. Answers once the primary has confirmed it, usually within a few milliseconds.201 {status, hash, confirmations, holders, confirmed_in_us}

Network.

GET /api/overview
Genesis wallet, total supply, block and wallet counts, and the total still pending.{genesis, total_supply, block_count, wallet_count, pending_total}
GET /api/wallets
Every wallet, richest first. page starts at 1; per_page is 1–100, default 20.{page, per_page, total, items: [{address, balance, height, head, pending}]}
GET /api/transactions
Blocks, newest first, each with its amount, counterparty and whether it is still pending. Paged like /api/wallets. Add address= to keep only blocks on that wallet’s chain or paying it.{page, per_page, total, items: [block + {amount, counterparty, pending}]}
GET /api/activity
Sends and receives confirmed per minute over the last 60 minutes, oldest first. minute is a Unix timestamp. Counted since the server last started.{minutes: [{minute, sends, receives}]}
GET /api/events
A live stream (server-sent events) with one block event per confirmed block. See below.event: block · data: block
GET /api/holders
The chain holders, whether each is online or catching up, and which is the primary.{primary, holders: [{name, online, loading, catching_up, blocks, bytes, wallets}]}
GET /health
Answers if the API is up.{"status": "ok"}
Blocks

The block object.

type
open (a wallet’s first block), send or receive
algorithm
Always "ed25519" today. May be left out on input.
account
The address of the wallet whose chain this block is on
height
Position in that chain. The first block is 1.
previous
Hash of the block before it; zeros for an open
balance
The wallet’s balance after this block, in units
link
Send: the receiver’s address. Open or receive: the hash of the send it collects.
memo
0–128 bytes of UTF-8, no control characters, "" for none. Public and permanent.
hash
SHA-256 of the block’s bytes
signature
Ed25519 signature of hash by the wallet’s key
Example
{
  "type": "send",
  "algorithm": "ed25519",
  "account": "oe_ed25519_…",
  "height": 2,
  "previous": "<64 hex>",
  "balance": "2099999000000000",
  "link": "oe_ed25519_…",
  "memo": "March rent",
  "hash": "<64 hex>",
  "signature": "<128 hex>"
}

The block carries no amount. A send’s amount is the drop in balance from the block before it; a receive’s is the amount of the send it collects.

Making a payment

Read, sign, submit.

  1. 01

    Read the account

    GET /api/accounts/<sender> gives head, height and balance.

  2. 02

    Sign a send

    Build a send with height + 1, previous = head, balance = balance − amount and link = the receiver. Sign it and POST /api/blocks.

  3. 03

    Collect it

    The receiver reads /pending and, for each item, signs an open (if the account was 404) or a receive with link = send_hash and the same memo.

One block at a time per wallet

Each block moves the wallet’s head. Re-read the account (or use the hash and balance you just submitted) before building the next block, or the next one is refused as fork.

The memo must match

An open or receive must repeat the send’s memo exactly, so both chains hold the same signed text. Use it for an invoice or order number; anyone can read it.

Signing

Sign blocks in your own code.

What gets hashed and signed
"ONEEUR-BLOCK-V1"   15 bytes   format name
type                 1 byte    1 = open, 2 = send, 3 = receive
account             key        algorithm (1) + length (2) + public key
height               8 bytes   unsigned big-endian
previous            32 bytes   hash of the previous block
balance             16 bytes   unsigned big-endian, after this block
link                key / 32   send: receiver · open/receive: the send’s hash
memo length          1 byte    0–128
memo                 0–128     UTF-8 text

key       = 0x00 (Ed25519) + 0x0020 (32) + 32-byte public key
hash      = SHA-256(bytes above)
signature = Ed25519(wallet seed, hash)
address   = "oe_ed25519_" + base32(public key + SHA-256(public key)[0..4])
            (RFC 4648 alphabet, lowercase, no padding)
JavaScript · @noble/curves v2
npm install @noble/curves @noble/hashes
import { ed25519 } from '@noble/curves/ed25519.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';

const PREFIX = 'oe_ed25519_';
const B32 = 'abcdefghijklmnopqrstuvwxyz234567';

function base32(bytes) {
  let bits = 0, value = 0, out = '';
  for (const b of bytes) {
    value = (value << 8) | b; bits += 8;
    while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; }
  }
  if (bits > 0) out += B32[(value << (5 - bits)) & 31];
  return out;
}

function unbase32(str) {
  let bits = 0, value = 0; const out = [];
  for (const c of str) {
    const i = B32.indexOf(c);
    if (i < 0) throw new Error('invalid address');
    value = (value << 5) | i; bits += 5;
    if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; }
  }
  return Uint8Array.from(out);
}

export function encodeAddress(publicKey) {
  const check = sha256(publicKey).slice(0, 4);
  return PREFIX + base32(new Uint8Array([...publicKey, ...check]));
}

export function decodeAddress(address) {
  if (!address.startsWith(PREFIX)) throw new Error('invalid address');
  const raw = unbase32(address.slice(PREFIX.length));
  const key = raw.slice(0, 32), check = raw.slice(32);
  const expected = sha256(key).slice(0, 4);
  if (raw.length !== 36 || check.some((b, i) => b !== expected[i])) throw new Error('invalid address');
  return key;
}

// Keep `seed` secret, e.g. in the device's secure storage.
export function newWallet(seed = randomBytes(32)) {
  const publicKey = ed25519.getPublicKey(seed);
  return { seed, publicKey, address: encodeAddress(publicKey) };
}

const MAGIC = new TextEncoder().encode('ONEEUR-BLOCK-V1');
const TYPES = { open: 1, send: 2, receive: 3 };

function uintBE(value, bytes) {
  const out = new Uint8Array(bytes);
  let v = BigInt(value);
  for (let i = bytes - 1; i >= 0; i--) { out[i] = Number(v & 0xffn); v >>= 8n; }
  return out;
}

export function memoBytes(memo = '') {
  const bytes = new TextEncoder().encode(memo);
  if (bytes.length > 128 || /\p{Cc}/u.test(memo)) throw new Error('bad memo');
  return bytes;
}

// algorithm 0 (Ed25519), 2-byte key length, public key
const key = (publicKey) => [0, publicKey.length >> 8, publicKey.length & 0xff, ...publicKey];

export function signingPayload(b) {
  const memo = memoBytes(b.memo);
  return new Uint8Array([
    ...MAGIC,
    TYPES[b.type],
    ...key(b.account),
    ...uintBE(b.height, 8),
    ...b.previous,
    ...uintBE(b.balance, 16),
    ...(b.type === 'send' ? key(b.link) : b.link),
    memo.length,
    ...memo,
  ]);
}

export function signBlock(wallet, b) {
  const hash = sha256(signingPayload(b));
  return {
    type: b.type,
    algorithm: 'ed25519',
    account: encodeAddress(b.account),
    height: b.height,
    previous: bytesToHex(b.previous),
    balance: b.balance.toString(),
    link: b.type === 'send' ? encodeAddress(b.link) : bytesToHex(b.link),
    memo: b.memo ?? '',
    hash: bytesToHex(hash),
    signature: bytesToHex(ed25519.sign(hash, wallet.seed)),
  };
}

// account: JSON from GET /api/accounts/:address; amount: bigint units
export function sendBlock(wallet, account, toAddress, amount, memo = '') {
  const balance = BigInt(account.balance);
  if (amount <= 0n || amount > balance) throw new Error('bad amount');
  return signBlock(wallet, {
    type: 'send', account: wallet.publicKey, height: account.height + 1,
    previous: hexToBytes(account.head), balance: balance - amount,
    link: decodeAddress(toAddress), memo,
  });
}

// pending: one item from /pending; account: null if the wallet had a 404
export function receiveBlock(wallet, account, pending) {
  const amount = BigInt(pending.amount);
  const link = hexToBytes(pending.send_hash);
  const memo = pending.memo ?? '';
  if (!account) {
    return signBlock(wallet, {
      type: 'open', account: wallet.publicKey, height: 1,
      previous: new Uint8Array(32), balance: amount, link, memo,
    });
  }
  return signBlock(wallet, {
    type: 'receive', account: wallet.publicKey, height: account.height + 1,
    previous: hexToBytes(account.head), balance: BigInt(account.balance) + amount, link, memo,
  });
}
Send 1.5 OEUR
const API = 'https://gateway.oneeur.com';

const account = await (await fetch(`${API}/api/accounts/${me.address}`)).json();
const block = sendBlock(me, account, 'oe_ed25519_…', 150_000_000n, 'Invoice 1042');

const res = await fetch(`${API}/api/blocks`, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(block),
});
console.log(res.status, await res.json());
// 201 { status: 'confirmed', hash: '…', confirmations: 1, holders: 3, confirmed_in_us: 1840 }

Blocks signed with this code give the same hashes as the backend’s own signing code. In another language, rebuild the same bytes and compare your hash with one from /api/transactions.

Live events

Follow every block as it’s confirmed.

Server-sent events

GET /api/events keeps the connection open and sends one block event per confirmed block, with the block JSON as data. A comment line (: ping) arrives every 15 seconds so idle connections stay open. Events are not replayed: after a reconnect, catch up with /api/transactions.

curl -N https://gateway.oneeur.com/api/events

retry: 2000

event: block
data: {"type":"receive","account":"oe_ed25519_…","height":23920,…}
// Node 22+ or any EventSource client
const events = new EventSource('https://gateway.oneeur.com/api/events');
events.addEventListener('block', (e) => {
  const block = JSON.parse(e.data);
  if (block.type === 'send' && block.link === myAddress) {
    // a payment to you: collect it from /pending
  }
});
Errors

What can go wrong.

400
The request is wrong: invalid_address, invalid_hash, invalid_limit, invalid_page, malformed_block or malformed_json.{"error": "…"}
404
not_found: no such block, a wallet with no chain yet, or an unknown path.{"error": "not_found"}
413, 415
request_too_large (over 64 KB) or unsupported_media_type (not JSON).
422
The block was checked and refused. Don’t retry it unchanged; see the reasons below.{"status": "rejected", "reason": "…"}
503
Try again shortly. primary_unavailable: blocks can’t be confirmed right now. loading: a holder is still reading its files after a restart. unavailable: no holder answered a read.

Why a block is refused.

bad_signature
The signature doesn’t match the block and the wallet’s key. Any changed field causes this, so check your byte layout first.
bad_balance
The new balance doesn’t add up, or a send is for nothing or more than the wallet has.
fork
Another block already follows the same previous block. This is a double spend, or you built on an old head: re-read the account.
bad_previous, gap
The block doesn’t point to the wallet’s latest block, or skips a height.
account_not_open
A send or receive on a wallet that has no chain yet. Its first block must be an open.
unknown_send
An open or receive points to a send that doesn’t exist or isn’t for this wallet.
already_exists
The block was already added, or the send was already collected.
invalid_memo
Over 128 bytes, not UTF-8, or contains control characters.
memo_mismatch
An open or receive whose memo differs from its send’s.
self_send
A wallet can’t pay itself.
unsupported_algorithm
Anything but ed25519.
Good to know

Limits of the prototype.

One primary confirms

A 201 means the primary has added the block (confirmations: 1); copies reach the other holders in the background. The primary is trusted, and while it is down no block can be confirmed. There is no failover yet.

Lists can be a moment behind

Accounts, pending payments and a wallet’s chain come straight from the primary. Overview, wallets, transactions and blocks come from a read copy that is usually less than a millisecond behind. Right after you submit, look up your own wallet through /api/accounts.

Be gentle

There are no rate limits or spam protection yet. Please don’t poll in a tight loop: use /api/events to learn about new blocks.