Copyright © dibachain. GPL-3.0.
This is the complete reference for the JSON REST API served by DibaVault's on-device
HTTP server (components/connectivity/web_server.c).
The same API powers the embedded web UI and is intended for external clients —
including a planned companion mobile/desktop app — to integrate against the device.
Scope note. Every endpoint below is exactly what the firmware registers today. The API is deliberately small: status, provisioning, unlock/lock, the network and token registries, account/balance reads, and the build → sign → broadcast transaction flow.
- Base URL & transport
- Security model for API consumers
- Authentication & threat notes
- Conventions
- Error format & codes
- Endpoints
- Example flows (curl)
- Future app integration
- Transport: plain HTTP (no TLS) served by the device.
- AP mode (default): the device is its own WiFi access point; the API is at
http://192.168.4.1. This is the address the first-boot web UI uses. - STA / joined-router mode: when the device joins your router, it is reachable at the IP the router assigns it, on the same paths.
- Content type: request bodies are JSON; responses are JSON
(
application/json). Request bodies are capped at 4096 bytes. - All paths below are relative to the base URL, e.g.
GET /api/status→http://192.168.4.1/api/status.
The API is served by the untrusted connectivity layer. It never has access to private keys; it can only ask the secure core (the "vault") to act, through a single narrow IPC boundary. Every request the HTTP layer makes to the vault is tagged with the remote origin, so the vault applies its strictest policy. Consequences a client must design around:
- The mnemonic is never returned over HTTP after initial setup. A fresh seed's
words are returned exactly once, by
POST /api/provision/new, during first-boot provisioning. There is no endpoint that reveals an existing wallet's mnemonic — that is only possible from the local (on-device / serial) origin. - Signing requires physical confirmation.
/api/tx/sign,/api/tx/broadcastand/api/offline/signblock until the user reviews the decoded transaction on the device and presses OK. The HTTP call can wait up to ~120 seconds; a rejection or timeout returns an error (user_rejected/timeout). - The device recomputes what it signs. A client supplies transaction intent (recipient, amount, token, etc.); the vault re-derives the canonical signing payload itself and shows a human-readable summary. A client cannot smuggle opaque bytes past the on-screen review.
- No secrets in errors. Error codes are safe to surface; they never carry key material.
- There is no bearer-token / password authentication on the API itself. Access control is the WiFi layer: in AP mode the WPA2 password on the hotspot gates who can reach the device; in STA mode, anyone who can route to the device's IP on your LAN can call the API.
- Treat the device as the source of truth. A client should never assume it holds authority over funds. All sensitive operations (signing, and any mnemonic reveal) require on-device confirmation or a local origin and cannot be forced from HTTP. Design the app UI around "propose on the app, confirm on the device".
- Untrusted network posture. Because the transport is plain HTTP on a local
network, assume a co-located attacker could observe or tamper with API traffic.
DibaVault's guarantees do not rely on the transport being confidential: keys never
traverse it, and nothing is signed without the physical button. Still, prefer a
strong AP password, prefer AP-only over joining a shared LAN, and use air-gapped
offline signing for significant funds (see
SECURITY.md). - One operation at a time. The vault serializes signing/confirmation; concurrent sign requests will queue behind the one awaiting confirmation.
- Amounts in transaction requests are decimal strings in the smallest base unit
of the asset (wei / satoshi / sun / lamports), e.g.
"amount":"1000000000000000000"for 1 ETH. The device converts base units ↔ display units for the review screen. net_idxis the integer index of a network in the network registry (fromGET /api/networks).token_idxis the index of a token in the token registry (fromGET /api/tokens).- Enums are returned as integers in some responses; their meanings:
family(chain family):0= Bitcoin,1= EVM,2= Tron,3= Solana.display:0= none,1= SSD1306,2= SH1106,3= ST7789.confirm:0= none,1= 1-button,2= 2-button,3= 3-button.mode(WiFi):0= off (air-gapped),1= AP,2= STA,3= AP+STA.
- When writing a network or token,
familymay be given either as the integer above or as a string:"bitcoin","evm","tron","solana"(anything unrecognized defaults to EVM).
On failure the server responds with HTTP 400 Bad Request and a JSON body:
{ "error": "locked", "message": "human-readable detail" }error is a stable machine code; message is a human-readable hint (may equal the
code). Vault-originated codes map from dv_err_t via errname():
error |
Meaning |
|---|---|
ok |
Success (not normally seen in an error body). |
locked |
Vault is locked; call POST /api/unlock first. |
bad_pin |
Wrong PIN supplied to unlock. |
pin_lockout |
Too many wrong PINs; unlock is temporarily blocked. |
user_rejected |
The on-device physical confirmation was declined. |
timeout |
The confirmation (or vault request) timed out. |
not_provisioned |
No seed yet; run provisioning first. |
already_provisioned |
A seed already exists; refusing to overwrite (remote origin cannot wipe it). |
boundary_denied |
A request tried to cross the key boundary (e.g. reveal mnemonic over HTTP). |
unsupported_chain |
The chain family is not supported for this operation. |
error |
Generic/unclassified vault error. |
HTTP-layer validation codes (returned the same way):
error |
Meaning |
|---|---|
bad_body |
Missing/oversized request body (empty or > 4096 bytes). |
bad_json |
Body is not valid JSON. |
bad_net |
net_idx does not refer to a configured network. |
bad_token |
token_idx does not refer to a configured token. |
bad_tx |
Transaction intent is malformed (e.g. missing to). |
invalid_hal |
Proposed hardware config is invalid (message carries the reason). |
add_failed |
Could not add the network/token (registry full or invalid). |
del_failed |
Could not delete the network/token at that index. |
no_contract |
Token create was missing a contract. |
describe_failed |
The chain could not describe the transaction for review. |
rpc_failed |
An RPC query (balance / broadcast) failed. |
no_url |
OTA apply was called without an image url. |
ota_failed |
OTA image download or signature verification failed. |
Note: a successful call returns HTTP
200. Some endpoints (/api/unlock) return200with anok:falsefield plus anerrorcode rather than a400, so that a client can readattempts_lefton a wrong PIN — see the endpoint for details.
Device status. No auth, no body.
Response:
{
"provisioned": true,
"locked": false,
"fw_version": "1.2.3",
"attempts_left": 5,
"airgap": false,
"mode": 1,
"sta_connected": false,
"hardware": { "display": 1, "confirm": 2 }
}| Field | Type | Meaning |
|---|---|---|
provisioned |
bool | A seed exists on the device. |
locked |
bool | Vault is locked (needs unlock). |
fw_version |
string | Firmware version. |
attempts_left |
number | Remaining PIN attempts before lockout. |
airgap |
bool | Radio fully off (air-gapped). |
mode |
number | WiFi mode (see enums). |
sta_connected |
bool | Joined-router link is up. |
hardware.display |
number | Configured display type. |
hardware.confirm |
number | Configured confirm mode. |
Current hardware (HAL) configuration.
Response:
{
"display": 1, "i2c_sda": 8, "i2c_scl": 9, "i2c_addr": 60,
"confirm": 2, "btn_ok": 4, "btn_cancel": 5, "btn_up": -1, "btn_down": -1,
"active_low": true
}Pins set to -1 are unused. i2c_addr is decimal (e.g. 60 = 0x3C).
Update the hardware configuration (used by first-boot setup). Body may contain any subset of the fields above; omitted fields keep their current value.
Request:
{ "display": 1, "i2c_sda": 8, "i2c_scl": 9, "i2c_addr": 60,
"confirm": 2, "btn_ok": 4, "btn_cancel": 5, "active_low": true }Response (success): { "ok": true }
Response (invalid): 400 with { "error": "invalid_hal", "message": "<reason>" }
where the message explains which pin/field was rejected (e.g. a strapping/flash pin,
a duplicate, or an out-of-range GPIO).
Provisioning is only meaningful on an unprovisioned device (or from the local
origin). Over HTTP it cannot overwrite an existing seed — that returns
already_provisioned.
Generate a fresh seed and return its recovery words once (first-boot only).
Request:
{ "word_count": 24, "pin": "123456", "passphrase": "" }| Field | Type | Notes |
|---|---|---|
word_count |
number | 12, 18 or 24 (defaults to 12). |
pin |
string | PIN to set (used to wrap the seed). |
passphrase |
string | Optional BIP39 passphrase ("25th word"). |
Response:
{ "mnemonic": "word1 word2 … word24" }This is the only time the words are returned over HTTP, and only over the AP-only setup UI. Write them down offline. There is no endpoint to retrieve them again over the network.
Import an existing mnemonic.
Request:
{ "mnemonic": "word1 word2 … word12", "pin": "123456", "passphrase": "" }Response: { "ok": true } (or an error such as already_provisioned).
Acknowledge that the user has written down the recovery words. The seed is already
sealed by /api/provision/new; this endpoint persists nothing server-side and is a
UI checkpoint. No body required.
Response: { "ok": true }
Submit the PIN to unlock the vault for the session.
Request:
{ "pin": "123456" }Response (always HTTP 200):
{ "ok": true, "attempts_left": 5 }On a wrong PIN, ok is false, attempts_left decreases, and an error field is
included (e.g. bad_pin, pin_lockout):
{ "ok": false, "attempts_left": 4, "error": "bad_pin" }Wipe the session keys from RAM (re-lock). No body.
Response: { "ok": true }
The network/RPC registry (non-sensitive config). Up to DV_NET_MAX (32) entries.
List configured networks.
Response:
{
"networks": [
{
"idx": 0, "family": 1, "evm_chain_id": 1,
"name": "Ethereum", "rpc_url": "https://rpc.example",
"symbol": "ETH", "decimals": 18, "explorer": "https://etherscan.io"
}
]
}idx is the net_idx used by other endpoints.
Add a network.
Request:
{
"family": "evm", "evm_chain_id": 137,
"name": "Polygon", "rpc_url": "https://polygon-rpc.example",
"symbol": "MATIC", "decimals": 18, "explorer": "https://polygonscan.com"
}family accepts the integer or string form (see Conventions).
Response: { "ok": true } (or add_failed if the registry is full/invalid).
Remove the network at the given registry index.
Response: { "ok": true } (or del_failed).
The token registry (ERC-20 / TRC-20 / SPL), non-sensitive config. Up to
DV_TOKEN_MAX (64) entries. A token is bound to a chain family and, for EVM, an
evm_chain_id.
List configured tokens. Optional query ?net_idx=<i> filters to tokens matching that
network's (family, evm_chain_id).
Response:
{
"tokens": [
{
"idx": 0, "family": 1, "evm_chain_id": 1,
"symbol": "USDT", "name": "Tether USD",
"contract": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"decimals": 6
}
]
}idx is the token_idx used by /api/token/balance. contract is an ERC-20 /
TRC-20 address, or an SPL mint for Solana.
Register a token. You may pass net_idx to inherit family / evm_chain_id from a
network, and/or set them explicitly.
Request:
{
"net_idx": 0,
"symbol": "USDC", "name": "USD Coin",
"contract": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"decimals": 6
}Or fully explicit:
{
"family": "evm", "evm_chain_id": 1,
"symbol": "USDC", "name": "USD Coin",
"contract": "0xA0b8…eB48", "decimals": 6
}contract is required (no_contract otherwise).
Response: { "ok": true } (or add_failed).
Remove the token at the given registry index.
Response: { "ok": true } (or del_failed).
Read a token balance for an address via the network's RPC endpoint.
Query params:
| Param | Required | Meaning |
|---|---|---|
net_idx |
yes | Network to query through (its RPC + family). |
token_idx |
yes | Token in the registry (provides contract + decimals). |
address |
yes | Owner address to query. |
Example: GET /api/token/balance?net_idx=0&token_idx=0&address=0xabc…
Response:
{ "balance": "12500000", "decimals": 6, "symbol": "USDT" }balance is a decimal string in base units; divide by 10^decimals for display.
Returns bad_net / bad_token for bad indices, or rpc_failed on a query error.
Derive and return the public account (address) for a network. Requires the vault to be unlocked (derivation happens in the secure core; no private material is returned).
Query params: net_idx (default 0).
Response:
{ "address": "0x1234…abcd", "symbol": "ETH" }Returns locked if the vault is locked, or bad_net for a bad index.
Native (coin) balance for an address via the network's RPC.
Query params:
| Param | Required | Meaning |
|---|---|---|
net_idx |
yes (default 0) |
Network to query. |
address |
yes | Address to query. |
Example: GET /api/balance?net_idx=0&address=0xabc…
Response:
{ "balance": "1000000000000000000", "decimals": 18, "symbol": "ETH" }The flow is: build (get a decoded review) → sign (physical confirm) → optionally broadcast. All three take the same transaction-intent body.
Transaction intent body fields:
| Field | Type | Meaning |
|---|---|---|
net_idx |
number | Network to sign for (provides family + chain-id + RPC). |
to |
string | Recipient address. Required. |
amount |
string | Amount in base units (decimal string). |
token_contract |
string | Optional: ERC-20/TRC-20 address or SPL mint for a token transfer. Omit for a native transfer. |
token_decimals |
number | Optional: decimals for the token transfer. |
nonce |
number | Optional: EVM/Tron nonce (the device fills chain fields via RPC if omitted). |
eip1559 |
bool | Optional: use an EIP-1559 (type-2) EVM transaction. |
The device fills in nonce/gas/fees/UTXOs/blockhash via RPC (rpc_prepare_tx) where
they are not supplied.
Return a decoded, human-readable review of the transaction without signing. Use this to show the user what they are about to sign.
Request:
{ "net_idx": 0, "to": "0xRecipient…", "amount": "1000000000000000000", "eip1559": true }Response:
{
"to": "0xRecipient…",
"amount": "1.0",
"symbol": "ETH",
"fee": "0.00021",
"nonce": 7,
"net_idx": 0
}amount/fee are in display units. Returns bad_net, bad_tx, or
describe_failed.
Sign the transaction. Blocks until the user confirms on the device (up to ~120s).
Same body as /api/tx/build.
Response:
{ "signed_hex": "02f8..." }signed_hex is the fully-serialized, broadcast-ready signed transaction as a hex
string. Returns user_rejected if the user declines, timeout if no confirmation
arrives, or locked if the vault is locked.
Same as /api/tx/sign, but after signing the device broadcasts the signed
transaction via the network's RPC and returns the transaction id.
Response (broadcast supported):
{ "signed_hex": "02f8...", "txid": "0xhash…" }Response (chain has no broadcast path configured):
{ "signed_hex": "02f8...", "warning": "signed ok but broadcast unsupported for this chain" }Identical to /api/tx/sign (signs, never broadcasts). Intended for the air-gapped
workflow where the radio is off and the signed hex is carried to an online machine
(e.g. via QR). Returns { "signed_hex": "…" }.
Current WiFi configuration and mode.
Response:
{ "ap_ssid": "DibaVault", "sta_ssid": "HomeNet", "sta_updates_only": true, "mode": 1 }Passwords are never returned.
Update WiFi. Two shapes are accepted.
Air-gap toggle (takes precedence if present): turns the radio fully off or back to AP mode.
{ "airgap": true }airgap:true → radio off (air-gapped); airgap:false → AP mode.
Configuration update: any subset of the following; omitted fields are unchanged.
{
"ap_ssid": "DibaVault",
"ap_pass": "a-strong-ap-password",
"sta_ssid": "HomeNet",
"sta_pass": "home-wifi-password",
"sta_updates_only": true,
"connect_sta": true
}If connect_sta is true, the device switches to AP+STA mode (joins the router
while keeping the hotspot). sta_updates_only is a policy flag restricting the
joined link to updates + read-only.
Response: { "ok": true }
Check for a firmware update on the dibachain release channel (or configured mirror).
Response:
{ "current": "1.2.3", "update_available": false }Download, verify against the pinned dibachain signing key, install to the inactive slot, then activate and reboot. An image that fails the signature check is rejected.
Request:
{ "url": "https://releases.example/dibavault-esp32s3-signed.bin" }Response: { "ok": true } is sent, then the device reboots into the new image.
Returns no_url if url is missing, or ota_failed if download/verification fails.
Because the device reboots on success, the HTTP connection drops right after the
{ "ok": true }response. A client should treat a dropped connection following a200as "update applied, device rebooting" and re-poll/api/statusafterwards.
Assume the device is at http://192.168.4.1.
1. Status
curl http://192.168.4.1/api/status2. Unlock
curl -X POST http://192.168.4.1/api/unlock \
-H 'Content-Type: application/json' \
-d '{"pin":"123456"}'3. List networks (find a net_idx)
curl http://192.168.4.1/api/networks4. Add a token (USDC on network 0)
curl -X POST http://192.168.4.1/api/tokens \
-H 'Content-Type: application/json' \
-d '{"net_idx":0,"symbol":"USDC","name":"USD Coin",
"contract":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","decimals":6}'5. Get an account address, then its native and token balances
curl "http://192.168.4.1/api/accounts?net_idx=0"
# -> {"address":"0xABC…","symbol":"ETH"}
curl "http://192.168.4.1/api/balance?net_idx=0&address=0xABC…"
curl "http://192.168.4.1/api/token/balance?net_idx=0&token_idx=0&address=0xABC…"6. Build → sign → broadcast a native transfer
# Build a review (nothing is signed yet)
curl -X POST http://192.168.4.1/api/tx/build \
-H 'Content-Type: application/json' \
-d '{"net_idx":0,"to":"0xRecipient…","amount":"1000000000000000000","eip1559":true}'
# Sign (blocks until you press OK on the device), returns signed_hex
curl -X POST http://192.168.4.1/api/tx/sign \
-H 'Content-Type: application/json' \
-d '{"net_idx":0,"to":"0xRecipient…","amount":"1000000000000000000","eip1559":true}'
# Or sign AND broadcast in one call, returns signed_hex + txid
curl -X POST http://192.168.4.1/api/tx/broadcast \
-H 'Content-Type: application/json' \
-d '{"net_idx":0,"to":"0xRecipient…","amount":"1000000000000000000","eip1559":true}'7. Build → sign a token transfer (add token_contract + token_decimals)
curl -X POST http://192.168.4.1/api/tx/broadcast \
-H 'Content-Type: application/json' \
-d '{"net_idx":0,"to":"0xRecipient…","amount":"1000000",
"token_contract":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","token_decimals":6}'The API is documented first so a companion mobile/desktop app and the firmware can evolve against a stable contract. A well-behaved app would:
- Pair with the device by joining its AP (or reaching it on the LAN) and calling
GET /api/statusto learnprovisioned,locked,airgapand the firmware version. - Guide first-boot on a fresh device:
POST /api/halto describe the hardware,POST /api/provision/new(showing the returned words to the user to write down), thenPOST /api/provision/confirm. The app must never persist the mnemonic; it is shown once for the user to record offline. - Unlock per session with
POST /api/unlock, surfacingattempts_leftand respectingpin_lockout. - Manage chains and tokens with the
/api/networksand/api/tokensendpoints, caching the returnedidxvalues for later calls. - Show balances via
/api/accounts,/api/balanceand/api/token/balance. - Send funds with the propose-on-app, confirm-on-device pattern: call
/api/tx/buildto render a review in the app, then/api/tx/signor/api/tx/broadcastand prompt the user to approve on the device. Handleuser_rejectedandtimeoutgracefully. - Support air-gapped signing by toggling
POST /api/wifi {"airgap":true}and, in an offline session, using/api/offline/sign(or the serial console) with the signed hex carried back online out-of-band (e.g. via QR). - Offer updates through
/api/ota/checkand/api/ota/apply, treating a dropped connection after a200as "rebooting into the new image".
Design principle: the device is the source of truth and the point of confirmation. The app proposes and displays; the device decides and signs. No API call can extract a key or force a signature without the on-device button.