Reference
API Reference
eIOU API Reference
Complete API documentation for the eIOU Docker node REST API.
Amounts. Monetary amounts in API responses are exact decimal strings (e.g.
"100","100.5","9000000000.12345678"), never JSON numbers, so a client never loses precision on large balances the way an IEEE-754 double would. Trailing fractional zeros are trimmed uniformly across endpoints ("5", not"5.00000000"). The exception is the pending-P2P endpoints, which report amounts as integer minor units (1e-8 each), documented per field. In request bodies theamountfield accepts either a JSON number or a decimal string. Parse response amounts with a decimal-aware type (or integer, for pending-P2P) rather than a native float.
Table of Contents
- Authentication
- Response Format
- Error Codes
- Wallet Endpoints
- Contact Endpoints
- Payment Request Endpoints
- System Endpoints
- Tx Drop Endpoints
- Backup Endpoints
- Export Endpoints
- Payback Methods Endpoints
- API Key Management
Authentication
The eIOU API uses HMAC-SHA256 signature-based authentication to secure all requests.
Required Headers
| Header | Description |
|---|---|
X-API-Key |
Your API key ID (format: eiou_...) |
X-API-Timestamp |
Unix timestamp of request (seconds since epoch) |
X-API-Nonce |
Unique request identifier (8-64 chars, prevents replay attacks) |
X-API-Signature |
HMAC-SHA256 signature of the request |
Signature Generation
The signature is computed as:
signature = HMAC-SHA256(string_to_sign, api_secret)
Where string_to_sign is:
{METHOD}\n{PATH}\n{TIMESTAMP}\n{NONCE}\n{BODY}
METHOD: HTTP method in uppercase (GET, POST, PUT, DELETE)PATH: Request path (e.g.,/api/v1/wallet/balance)TIMESTAMP: Same Unix timestamp as the headerNONCE: Same unique nonce as the headerBODY: Request body (empty string for GET requests)
Security Notes
- Timestamps must be within 5 minutes of server time
- Each nonce can only be used once within the timestamp window (prevents replay attacks)
- API secrets are never sent in requests - only the computed signature
- Rate limiting is enforced per API key (default: 100 requests/minute)
- Proxy headers (
X-Forwarded-For,CF-Connecting-IP) are only trusted whenREMOTE_ADDRis in the trusted proxies list. Configure via CLI:eiou changesettings trustedProxies "10.0.0.1,172.16.0.1"(see CLI Reference — changesettings). TheTRUSTED_PROXIESenvironment variable takes precedence if set.
Example: Bash
#!/bin/bash
API_KEY="eiou_your_key_id"
API_SECRET="your_api_secret"
METHOD="GET"
PATH="/api/v1/wallet/balance"
TIMESTAMP=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY=""
STRING_TO_SIGN="${METHOD}\n${PATH}\n${TIMESTAMP}\n${NONCE}\n${BODY}"
SIGNATURE=$(echo -en "$STRING_TO_SIGN" | openssl dgst -sha256 -hmac "$API_SECRET" | cut -d' ' -f2)
curl -X GET "http://localhost:8080${PATH}" \
-H "X-API-Key: $API_KEY" \
-H "X-API-Timestamp: $TIMESTAMP" \
-H "X-API-Nonce: $NONCE" \
-H "X-API-Signature: $SIGNATURE"
Example: PHP
<?php
$apiKey = 'eiou_your_key_id';
$apiSecret = 'your_api_secret';
$method = 'GET';
$path = '/api/v1/wallet/balance';
$timestamp = time();
$nonce = bin2hex(random_bytes(16));
$body = '';
$stringToSign = "{$method}\n{$path}\n{$timestamp}\n{$nonce}\n{$body}";
$signature = hash_hmac('sha256', $stringToSign, $apiSecret);
$ch = curl_init("http://localhost:8080{$path}");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"X-API-Key: {$apiKey}",
"X-API-Timestamp: {$timestamp}",
"X-API-Nonce: {$nonce}",
"X-API-Signature: {$signature}"
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
Example: JavaScript
const crypto = require('crypto');
const apiKey = 'eiou_your_key_id';
const apiSecret = 'your_api_secret';
const method = 'GET';
const path = '/api/v1/wallet/balance';
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(16).toString('hex');
const body = '';
const stringToSign = `${method}\n${path}\n${timestamp}\n${nonce}\n${body}`;
const signature = crypto.createHmac('sha256', apiSecret).update(stringToSign).digest('hex');
fetch(`http://localhost:8080${path}`, {
method: method,
headers: {
'X-API-Key': apiKey,
'X-API-Timestamp': timestamp.toString(),
'X-API-Nonce': nonce,
'X-API-Signature': signature
}
});
Example: Python
import hashlib
import hmac
import secrets
import time
import requests
api_key = 'eiou_your_key_id'
api_secret = 'your_api_secret'
method = 'GET'
path = '/api/v1/wallet/balance'
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
body = ''
string_to_sign = f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}"
signature = hmac.new(
api_secret.encode(),
string_to_sign.encode(),
hashlib.sha256
).hexdigest()
response = requests.get(
f"http://localhost:8080{path}",
headers={
'X-API-Key': api_key,
'X-API-Timestamp': timestamp,
'X-API-Nonce': nonce,
'X-API-Signature': signature
}
)
print(response.json())
Response Format
Success Response
{
"success": true,
"data": {
// Response data here
},
"request_id": "req_abc123",
"timestamp": "2026-01-23T12:00:00Z"
}
Error Response
{
"success": false,
"error": {
"code": "error_code",
"message": "Human-readable error message"
},
"request_id": "req_abc123",
"timestamp": "2026-01-23T12:00:00Z"
}
Error Codes
Authentication Errors (401)
| Code | Description |
|---|---|
auth_missing_key |
X-API-Key header not provided |
auth_missing_timestamp |
X-API-Timestamp header not provided |
auth_missing_signature |
X-API-Signature header not provided |
auth_invalid_key |
API key does not exist |
auth_invalid_signature |
HMAC signature verification failed |
auth_invalid_timestamp |
Timestamp is not a valid number |
auth_expired_timestamp |
Timestamp is too old (>5 minutes) |
auth_missing_nonce |
X-API-Nonce header not provided |
auth_invalid_nonce |
Nonce format invalid (must be 8-64 characters) |
auth_replay_detected |
Nonce has already been used (replay attack) |
auth_key_disabled |
API key has been disabled |
auth_key_expired |
API key has expired |
Permission Errors (403)
| Code | Description |
|---|---|
permission_denied |
API key lacks required permission |
Resource Errors (404)
| Code | Description |
|---|---|
invalid_path |
Invalid API path |
unknown_resource |
Resource type not found |
unknown_action |
Action not found for resource |
contact_not_found |
Contact does not exist |
Validation Errors (400)
| Code | Description |
|---|---|
invalid_json |
Request body is not valid JSON |
missing_field |
Required field is missing |
invalid_amount |
Transaction amount is invalid (non-numeric, negative, or below currency minimum) |
invalid_address |
Address format is invalid (must be HTTP, HTTPS, or Tor .onion) |
invalid_currency |
Currency code is unsupported or invalid format |
invalid_name |
Contact name is invalid (empty, too short/long, or contains invalid characters) |
invalid_fee |
Fee percentage is out of range |
invalid_credit |
Credit limit is invalid (negative or exceeds maximum) |
invalid_description |
Description exceeds maximum length |
invalid_hash |
Transaction hash is not a valid 64-character hex string |
self_send |
Cannot send a transaction to yourself |
missing_currency |
Currency is required when updating fee or credit limit |
no_fields |
No fields provided for update |
validation_error |
One or more setting values failed validation |
unknown_setting |
Unrecognized setting name in update request |
Rate Limiting (429)
| Code | Description |
|---|---|
rate_limit_exceeded |
Too many requests per minute |
Operation Errors
| Code | Description |
|---|---|
key_not_found |
API key does not exist |
ping_failed |
Contact ping operation failed |
ping_error |
Error during contact ping |
update_failed |
Contact update operation failed |
update_error |
Error during contact update |
delete_failed |
Contact deletion failed |
delete_error |
Error during contact deletion |
block_failed |
Contact block operation failed |
block_error |
Error during block operation |
unblock_failed |
Contact unblock operation failed |
unblock_error |
Error during unblock operation |
contact_add_failed |
Failed to add contact |
chaindrop_failed |
Tx drop operation failed |
chaindrop_error |
Error during tx drop operation |
p2p_error |
P2P approval operation failed |
candidate_not_found |
Selected route candidate not found |
candidate_mismatch |
Candidate does not belong to this transaction |
candidate_selection_required |
Multiple candidates exist, must specify candidate_id |
not_originator |
Only the transaction originator can approve/reject |
no_route |
No route available for this transaction |
sync_error |
Sync operation failed |
shutdown_error |
Shutdown operation failed |
start_error |
Start operation failed |
Server Errors (500)
| Code | Description |
|---|---|
internal_error |
Unexpected server error |
transaction_error |
Transaction processing failed |
contact_error |
Contact operation failed |
Wallet Endpoints
GET /api/v1/wallet/balance
Get wallet balances grouped by contact.
Alias: /api/v1/wallet/balances (plural form also accepted)
Permission: wallet:read
Response:
{
"success": true,
"data": {
"balances": [
{
"contact_name": "Alice",
"address": "http://alice.local:8080",
"currency": "VWL",
"received": "150",
"sent": "50",
"net_balance": "100"
}
]
}
}
curl Example:
curl -X GET "http://localhost:8080/api/v1/wallet/balance" \
-H "X-API-Key: $API_KEY" \
-H "X-API-Timestamp: $TIMESTAMP" \
-H "X-API-Nonce: $NONCE" \
-H "X-API-Signature: $SIGNATURE"
GET /api/v1/wallet/info
Get wallet public key, addresses, fee earnings, and available credit.
Permission: wallet:read
Response:
{
"success": true,
"data": {
"public_key_hash": "abc123...",
"addresses": {
"http": "http://node.local:8080",
"https": "https://node.local:8443",
"tor": "abc123...onion"
},
"fee_earnings": [
{
"currency": "VWL",
"total_amount": "12.5"
}
],
"available_credit": [
{
"currency": "VWL",
"total_available_credit": "250"
}
]
}
}
Fields:
fee_earnings: Total fees earned from P2P relay transactions, grouped by currencyavailable_credit: Total available credit extended by all contacts, grouped by currency
GET /api/v1/wallet/overview
Get dashboard summary with balances and recent transactions.
Permission: wallet:read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
transaction_limit |
int | 5 | Number of recent transactions (max: 20) |
Response:
{
"success": true,
"data": {
"balances": [
{
"currency": "VWL",
"total_balance": "500"
}
],
"total_available_credit": [
{
"currency": "VWL",
"total": "250.00"
}
],
"recent_transactions": [
{
"txid": "tx_abc123",
"type": "sent",
"tx_type": "standard",
"status": "completed",
"amount": "25",
"currency": "VWL",
"counterparty_name": "Bob",
"description": "Payment for services",
"timestamp": "2026-01-23T12:00:00Z"
}
],
"transaction_count": 1
}
}
Fields:
total_available_credit: Sum of available credit across all contacts, grouped by currency. Received via ping/pong from contacts; refreshed on ~5 minute intervals.
GET /api/v1/wallet/transactions
Get paginated transaction history.
Permission: wallet:read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
int | 50 | Number of transactions (max: 100) |
offset |
int | 0 | Pagination offset |
type |
string | null | Filter by type: sent, received, relay |
contact |
string | null | Filter by contact name or address |
Response:
{
"success": true,
"data": {
"transactions": [
{
"txid": "tx_abc123",
"type": "sent",
"tx_type": "standard",
"status": "completed",
"amount": "25",
"currency": "VWL",
"sender_address": "http://alice.local:8080",
"receiver_address": "http://bob.local:8080",
"description": "Invoice #123",
"memo": "standard",
"timestamp": "2026-01-23T12:00:00Z"
}
],
"pagination": {
"total": 150,
"limit": 50,
"offset": 0
}
}
}
POST /api/v1/wallet/send
Send a transaction to a contact.
Permission: wallet:send
Request Body:
{
"address": "http://bob.local:8080",
"amount": 25.00,
"currency": "VWL",
"description": "Payment for services"
}
| Field | Type | Required | Description |
|---|---|---|---|
address |
string | Yes | Recipient address (HTTP, HTTPS, or Tor) or contact name. Cannot be your own address |
amount |
number or string | Yes | Amount to send (must be > 0, up to 8 decimal places). Minimum: 0.00000001. Maximum: ~2.3 quintillion (TRANSACTION_MAX_AMOUNT). Returned as a trimmed decimal string (e.g. "25") |
currency |
string | Yes | Currency code, 3-10 alphanumeric characters (case-insensitive; fiat normalized to uppercase, mixed-case tokens preserved). Must be in the allowed currencies list |
description |
string | No | Optional transaction description (max 255 characters). Only visible to the final recipient |
best_fee |
boolean | No | [Experimental] Use best-fee routing: collects all P2P route responses and selects the lowest accumulated fee. May be slower than default fast mode. |
Response:
{
"success": true,
"data": {
"status": "sent",
"message": "Transaction sent successfully",
"recipient": "Bob",
"recipient_address": "http://bob.local:8080",
"amount": "25",
"currency": "VWL",
"txid": "tx_abc123",
"type": "standard"
}
}
curl Example:
curl -X POST "http://localhost:8080/api/v1/wallet/send" \
-H "X-API-Key: $API_KEY" \
-H "X-API-Timestamp: $TIMESTAMP" \
-H "X-API-Nonce: $NONCE" \
-H "X-API-Signature: $SIGNATURE" \
-H "Content-Type: application/json" \
-d '{"address":"http://bob.local:8080","amount":25.00,"currency":"VWL"}'
POST /api/v1/wallet/refund
Return a previously-received transaction to its original sender, in full or in part. You supply only the txid; the recipient (the original sender) and the currency are taken from the wallet’s stored record, and the amount is capped at what was actually received, so a refund cannot be redirected, re-priced, or inflated.
Permission: wallet:send
Request Body:
{
"txid": "tx_abc123",
"amount": "5"
}
| Field | Type | Required | Description |
|---|---|---|---|
txid |
string | Yes | The txid of an inbound, settled (received) transaction to return. Only received transactions are eligible |
amount |
string|number | No | Amount to return. Omit (or pass an empty string) to return the full remaining; a smaller value makes a partial refund. Capped so the total returned across all refunds never exceeds the amount received |
Response:
{
"success": true,
"data": {
"status": "partially_refunded",
"message": "Returned 5 VWL to the original sender (45 VWL still refundable).",
"original_txid": "tx_abc123",
"refund_txid": "tx_def456",
"amount": "5",
"currency": "VWL",
"original_amount": "50",
"already_refunded": "0",
"remaining": "45",
"fully_refunded": false,
"note": "Any P2P return-routing fee is paid on top, from your balance."
}
}
status is fully_refunded once the remaining reaches zero, otherwise partially_refunded. Each refund is an ordinary outgoing transaction; when it routes over P2P the return-routing fee is paid on top from your balance, and the original inbound fees the sender paid are sunk.
Errors: 400 invalid_refund for a malformed, unknown, not-received, or not-settled txid, an amount over the refundable remainder, or an already fully-refunded original; 400 missing_field when txid is absent.
curl Example:
curl -X POST "http://localhost:8080/api/v1/wallet/refund" \
-H "X-API-Key: $API_KEY" \
-H "X-API-Timestamp: $TIMESTAMP" \
-H "X-API-Nonce: $NONCE" \
-H "X-API-Signature: $SIGNATURE" \
-H "Content-Type: application/json" \
-d '{"txid":"tx_abc123","amount":"5"}'
Contact Endpoints
GET /api/v1/contacts
List all contacts.
Permission: contacts:read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
status |
string | accepted |
Filter by status: pending, accepted, blocked |
Response:
{
"success": true,
"data": {
"contacts": [
{
"name": "Bob",
"pubkey_hash": "abc123...",
"status": "accepted",
"currencies": [
{
"currency": "VWL",
"fee_percent": 0,
"credit_limit": "100",
"status": "accepted",
"direction": "outgoing",
"requested_min_unmet": false,
"requested_minimum": null
}
],
"my_available_credit": "95",
"addresses": {
"http": "http://bob.local:8080",
"https": null,
"tor": null
},
"created_at": "2026-01-01T00:00:00Z"
}
],
"count": 1
}
}
Fields:
currencies: Array of per-currency configurations withcurrency,fee_percent,credit_limit,status(accepted/pending),direction(incoming/outgoing), and the two minimum-credit fields below.requested_minimum: On anoutgoingrow where you sent this contact a request that required a minimum credit limit, the minimum you required (major units).nullonincomingrows and when you required no minimum.requested_min_unmet:truewhen this contact’s most recent ping/pong shows they extend you less credit than yourrequested_minimum, so you can spot a non-conforming peer. Clears on its own once they grant at least the minimum. Detection only: a contact’s extended credit is their own setting and cannot be forced, so this surfaces the mismatch rather than blocking.my_available_credit: How much credit you can use through this contact (received via ping/pong, ~5 min refresh).nullif not yet known. Stored per-currency in thecontact_credittable.
POST /api/v1/contacts
Add a new contact.
Permission: contacts:write
Request Body:
{
"address": "http://bob.local:8080",
"name": "Bob",
"fee_percent": 0,
"credit_limit": "100",
"currency": "VWL",
"requested_credit_limit": "500",
"requested_credit_enforced": true,
"description": "Hey, it's Dave!"
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
address |
string | Yes | - | Contact’s node address (HTTP, HTTPS, or Tor .onion) |
name |
string | Yes | - | Display name (2-50 characters, alphanumeric, spaces, dashes, underscores) |
fee_percent |
number | No | 0 | Per-contact routing fee percentage (0-100). Defaults to 0: the protocol charges no routing fee. Set a non-zero value only if you intend to charge this contact |
credit_limit |
number | No | 100.0 | Credit limit you extend to this contact (>= 0, up to 8 decimal places, max PHP_INT_MAX). Setting to 0 means you can be contacts but they cannot send transactions through you. Returned as a decimal string |
currency |
string | No | VWL | Currency code, 3-10 alphanumeric characters (case-insensitive; fiat normalized to uppercase, mixed-case tokens preserved). Must be in the allowed currencies list |
requested_credit_limit |
number | No | - | The credit limit you would like this contact to set for you. Sent as a suggestion — the recipient sees it pre-filled when accepting the request. If omitted, the recipient’s default credit limit is used |
requested_credit_enforced |
boolean | No | false | Treat requested_credit_limit as a hard minimum: the recipient may grant more but not less, and their wallet refuses to accept while granting less. Only honored alongside requested_credit_limit. The recipient’s pending-request response (GET /api/v1/contacts/pending) echoes this flag so clients can present/enforce it. Enforced by the recipient’s wallet, not cryptographically imposed |
description |
string | No | - | A short message sent with the contact request (max 255 characters). For non-Tor contacts, sent as a separate E2E encrypted follow-up after key exchange. For Tor contacts, included directly (protected by Tor transport encryption) |
Response (201 Created):
{
"success": true,
"data": {
"message": "Contact request sent successfully",
"status": "pending",
"address": "http://bob.local:8080",
"name": "Bob"
}
}
GET /api/v1/contacts/pending
Get all pending contact requests (incoming and outgoing).
Permission: contacts:read
Response:
{
"success": true,
"data": {
"pending": {
"incoming": [
{
"pubkey_hash": "abc123...",
"status": "pending",
"addresses": {
"http": "http://unknown.local:8080"
},
"created_at": "2026-01-23T12:00:00Z"
}
],
"outgoing": [
{
"name": "Charlie",
"pubkey_hash": "def456...",
"status": "pending",
"addresses": {
"http": "http://charlie.local:8080"
},
"created_at": "2026-01-23T12:00:00Z"
}
]
},
"counts": {
"incoming": 1,
"outgoing": 1,
"total": 2
}
}
}
GET /api/v1/contacts/search
Search contacts by name.
Permission: contacts:read
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
q or query |
string | Yes | Search term |
Response:
{
"success": true,
"data": {
"search_term": "bob",
"contacts": [
{
"name": "Bob",
"pubkey_hash": "abc123...",
"status": "accepted",
"addresses": {
"http": "http://bob.local:8080"
},
"my_available_credit": "85.5"
}
],
"count": 1
}
}
Contact fields:
my_available_credit: How much credit this contact extends to you (from pong, refreshed on ~5 min intervals).nullif not yet received. Stored per-currency incontact_credit. Per-currency fee/credit details are available viaGET /api/v1/contacts(withcurrenciesarray) orGET /api/v1/contacts/:address.
POST /api/v1/contacts/ping/:address
Ping a contact to check online status.
Permission: contacts:read
URL Parameters:
| Parameter | Description |
|---|---|
address |
Contact address (URL-encoded) |
Response:
{
"success": true,
"data": {
"contact_name": "Bob",
"online_status": "online",
"chain_valid": true,
"message": "Ping complete"
}
}
Note: Ping exchanges per-currency data with the contact: prevTxidsByCurrency (chain heads), chainStatusByCurrency (per-currency chain validity), and availableCreditByCurrency (per-currency available credit). The per-currency available credit is stored in the contact_credit table and reflected in the my_available_credit field on subsequent contact queries.
GET /api/v1/contacts/:address
Get contact details by address or name.
Permission: contacts:read
Response:
{
"success": true,
"data": {
"contact": {
"name": "Bob",
"pubkey_hash": "abc123...",
"status": "accepted",
"my_available_credit": "95",
"addresses": {
"http": "http://bob.local:8080",
"https": null,
"tor": null
},
"balance": {
"received": "100",
"sent": "50",
"net": "50"
},
"currencies": [
{
"currency": "VWL",
"fee_percent": 0,
"credit_limit": "100",
"status": "accepted",
"direction": "outgoing",
"requested_min_unmet": false,
"requested_minimum": null
}
],
"created_at": "2026-01-01T00:00:00Z"
}
}
}
Fields:
my_available_credit: How much credit you can use through this contact (received via ping/pong, ~5 min refresh).nullif not yet known. Stored per-currency incontact_credit.currencies: Array of per-currency configurations. Each entry hascurrency,fee_percent,credit_limit,status(accepted/pending),direction(incoming/outgoing= who initiated the relationship), and the minimum-credit fields below.requested_minimum/requested_min_unmet: On anoutgoingrow where you required a minimum credit limit,requested_minimumis that floor (major units, elsenull) andrequested_min_unmetistruewhen the contact’s latest ping/pong shows they extend you less than it. The flag clears once they grant at least the minimum. Detection only, since a contact’s extended credit is their own setting and cannot be forced.
Error Response (404):
{
"success": false,
"data": null,
"error": {
"message": "Contact not found",
"code": "contact_not_found"
},
"status_code": 404
}
PUT /api/v1/contacts/:address
Update contact information.
Permission: contacts:write
Request Body:
{
"name": "Robert",
"fee_percent": 1.5,
"credit_limit": "200",
"currency": "VWL"
}
All fields are optional. Only provided fields will be updated. currency is required when updating fee_percent or credit_limit — it specifies which currency’s settings to modify. Updates are applied to the contact_currencies table.
Response:
{
"success": true,
"data": {
"message": "Contact updated successfully",
"updated": {
"address": "http://bob.local:8080",
"name": "Robert",
"fee_percent": 1.5,
"credit_limit": "200",
"currency": "VWL"
}
}
}
Error Response (404):
{
"success": false,
"data": null,
"error": {
"message": "Contact not found for address: http://unknown:8080",
"code": "contact_not_found"
},
"status_code": 404
}
DELETE /api/v1/contacts/:address
Delete a contact.
Permission: contacts:write
Response:
{
"success": true,
"data": {
"message": "Contact deleted successfully",
"address": "http://bob.local:8080"
}
}
Error Response (404):
{
"success": false,
"data": null,
"error": {
"message": "Contact not found for address: http://unknown:8080",
"code": "contact_not_found"
},
"status_code": 404
}
POST /api/v1/contacts/block/:address
Block a contact.
Permission: contacts:write
Response:
{
"success": true,
"data": {
"message": "Contact blocked successfully",
"address": "http://bob.local:8080"
}
}
Error Response (404):
{
"success": false,
"data": null,
"error": {
"message": "Contact not found for address: http://unknown:8080",
"code": "contact_not_found"
},
"status_code": 404
}
POST /api/v1/contacts/unblock/:address
Unblock a contact.
Permission: contacts:write
Response:
{
"success": true,
"data": {
"message": "Contact unblocked successfully",
"address": "http://bob.local:8080"
}
}
Error Response (404):
{
"success": false,
"data": null,
"error": {
"message": "Contact not found for address: http://unknown:8080",
"code": "contact_not_found"
},
"status_code": 404
}
POST /api/v1/contacts/:hash/decisions
Apply a batched mix of accept / decline / defer decisions on an incoming contact request — the API mirror of the GUI batched-apply modal and eiou contact apply. Implementation is shared via ContactDecisionService::apply() so all three surfaces have identical partition + declines-first + first-accept-via-add semantics.
Permission: contacts:write
Request Body:
{
"decisions": [
{"currency": "VWL", "action": "accept", "fee": "0.01", "credit": "1000"},
{"currency": "EUR", "action": "decline"},
{"currency": "XRP", "action": "defer"}
],
"is_new_contact": true,
"contact_address": "http://bob.local:8080",
"contact_name": "Bob"
}
defer rows are intentional no-ops — drop them from the payload to skip a currency. is_new_contact, contact_address, contact_name are required only when the contact is still pending; for an already-accepted contact those three are ignored.
Response:
{
"success": true,
"data": {"accepted": ["VWL"], "declined": ["EUR"], "errors": []}
}
POST /api/v1/contacts/:hash/decline
Decline every pending currency on an incoming contact request in one shot. Idempotent — returns a 200 with an empty declined[] if there were no pending currencies.
Permission: contacts:write
Response:
{
"success": true,
"data": {"message": "Contact request declined", "declined": ["VWL", "EUR"]}
}
If a per-currency decline throws, the endpoint returns 500 with partial_decline_failure and both declined and errors in the error context so callers don’t need to re-query.
Decline notifications. Each declined currency triggers an async contact_currency_declined send to the requester so their outgoing-pending row is dropped without a manual retry on their side. After the per-currency loop, a single contact_declined is sent so the requester’s contact transaction itself is rejected. Both sends are async-best-effort — first attempt synchronous, failures land in the DLQ. The next ping/pong cycle reconciles any drift via the peerKnownCurrencies payload field.
GET /api/v1/contacts/:hash/currencies
List every currency configured for a contact (incoming + outgoing, pending + accepted + declined). Mirrors eiou contact currency list.
Permission: contacts:read
Response:
{
"success": true,
"data": {
"pubkey_hash": "abc123...",
"currencies": [
{"currency": "VWL", "status": "accepted", "direction": "incoming", "fee_percent": 100, "credit_limit": "1000"},
{"currency": "EUR", "status": "pending", "direction": "incoming"}
]
}
}
POST /api/v1/contacts/:hash/currencies
Propose a new currency to an already-accepted contact. Persists the local contact_currency row and sends a P2P request so the remote side can accept. Mirrors eiou contact currency add.
Permission: contacts:write
Request Body:
{"currency": "EUR", "fee": "0.02", "credit": "500"}
Response:
{
"success": true,
"data": {"message": "Currency added", "currency": "EUR"}
}
POST /api/v1/contacts/:hash/currency-accept
Accept a single pending currency. Routes through ContactDecisionService::apply() so the new-contact-first-accept-via-add semantics match the GUI batched-apply flow. Mirrors eiou contact currency accept.
Permission: contacts:write
Request Body:
{"currency": "EUR", "fee": "0.02", "credit": "500"}
Response:
{
"success": true,
"data": {"accepted": ["EUR"], "declined": [], "errors": []}
}
POST /api/v1/contacts/:hash/currency-decline
Decline a single pending currency. Mirrors eiou contact currency decline.
Permission: contacts:write
Request Body:
{"currency": "EUR"}
A contact_currency_declined notification is sent to the requester so their outgoing-pending row clears immediately. If the message is lost in flight (DLQ exhausts retries), the next ping/pong reconciles via peerKnownCurrencies. The requester’s retry succeeds in either case — addContact’s dispatcher detects a stale outgoing-pending row and routes it through addCurrencyToExisting instead of returning CONTACT_EXISTS.
Response:
{
"success": true,
"data": {"message": "Currency EUR declined", "currency": "EUR"}
}
POST /api/v1/contacts/:hash/currency-remove
Locally remove a currency configuration. Local-only — the peer is not notified. Use currency-decline to reject an incoming pending request, not this. Mirrors eiou contact currency remove.
Permission: contacts:write
Request Body:
{"currency": "EUR"}
Response:
{
"success": true,
"data": {"message": "Currency EUR removed locally", "currency": "EUR"}
}
Payment Request Endpoints
Payment requests let a user ask a contact to pay them a specific amount. The recipient can approve (which triggers sendEiou automatically) or decline. Both sides store the request locally; status updates are delivered via payment_request messages.
A request’s status is one of: pending (awaiting a response), sending (recipient approved and the payment is in flight, not yet confirmed), approved (payment completed), failed (payment terminally failed after approval — retryable via /requests/retry), declined, cancelled, or expired. sending and failed apply to the paying (incoming) side; an approval only becomes approved once the payment actually completes.
GET /api/v1/requests
List all payment requests — both incoming (requests sent to you) and outgoing (requests you sent).
Required permission: wallet:read
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 50 | Max records per direction (capped at 200) |
Response:
{
"success": true,
"data": {
"incoming": [
{
"id": 3,
"request_id": "abc123...",
"direction": "incoming",
"status": "pending",
"contact_name": "Bob",
"requester_address": "http://bob:8080",
"amount": "10",
"currency": "VWL",
"description": "Lunch",
"created_at": "2026-04-07 12:00:00"
}
],
"outgoing": [
{
"id": 1,
"request_id": "def456...",
"direction": "outgoing",
"status": "approved",
"contact_name": "Alice",
"amount": "5",
"currency": "VWL",
"resulting_txid": "txid-xyz",
"responded_at": "2026-04-07 12:05:00"
}
]
}
}
POST /api/v1/requests
Create and send a payment request to a contact.
Required permission: wallet:send
Request body:
{
"contact": "Bob",
"amount": "10.00",
"currency": "VWL",
"description": "Optional memo",
"address_type": "tor"
}
| Field | Type | Required | Description |
|---|---|---|---|
contact |
string | Yes | Name of an accepted contact |
amount |
string | Yes | Amount to request (e.g. "10.00") |
currency |
string | Yes | Currency code (e.g. "VWL") |
description |
string | No | Optional memo shown to the recipient |
address_type |
string | No | Preferred transport: tor, https, or http (auto-selects best if omitted) |
Response (201 Created):
{
"success": true,
"data": {
"request_id": "abc123def456..."
},
"message": "Payment request sent"
}
The request is stored locally as outgoing/pending immediately. The payment_request message is sent to the contact’s node. If delivery fails (contact temporarily offline), the request is still stored — the contact will receive it when they come back online via the normal message delivery retry system.
POST /api/v1/requests/approve
Approve an incoming payment request. Internally calls sendEiou to the requester using the full transaction pipeline (P2P routing, DLQ, retries).
A successful response means the payment was accepted into the send pipeline, not that it was delivered. The request moves to sending and is resolved automatically when the payment reaches a terminal state: approved once it completes, or failed if it terminally fails (direct rejected with no route, delivery exhausted, or expiry). A failed request can be re-sent with POST /api/v1/requests/retry. The requester is notified the payment was made only once it actually completes.
Required permission: wallet:send
Request body:
{
"request_id": "abc123def456...",
"payer_note": "paid via coinbase txid abc"
}
payer_note is optional. When supplied, it’s appended to the on-chain transaction description with " | " so the final description becomes "payment: <requester's description> | <your note>". The "payment: " prefix is dropped automatically if the joined string would otherwise exceed the 255-char on-chain ceiling.
The note is length-capped against this specific request’s existing description — max_note = 255 − len(requester_description) − 3 (separator). The server rejects an over-long note with HTTP 400 + error code payer_note_too_long rather than silently truncating; if the requester’s description already fills the budget the rejection message reads “Requester description leaves no room for a note”. Whitespace-only notes are treated as no note.
Response:
{
"success": true,
"data": {
"txid": "txid-abc123"
},
"message": "Payment sent"
}
Returns an error if: request not found, direction is not incoming, status is not pending, no return address on the request, the supplied payer_note exceeds the dynamic cap, or sendEiou fails.
POST /api/v1/requests/retry
Retry an incoming payment request whose previous payment terminally failed. Resets it to pending and re-runs the approval/send, minting a fresh outgoing payment linked to the same request.
Required permission: wallet:send
Request body:
{
"request_id": "abc123def456..."
}
Response:
{
"success": true,
"data": {
"txid": "txid-def456"
},
"message": "Retrying payment"
}
Returns an error (code retry_failed) if the request is not found, its direction is not incoming, or its status is not failed (only a terminally-failed payment can be retried).
POST /api/v1/requests/decline
Decline an incoming payment request. Sends a response message back to the requester.
Required permission: wallet:send
Request body:
{
"request_id": "abc123def456..."
}
Response:
{
"success": true,
"data": null,
"message": "Payment request declined"
}
DELETE /api/v1/requests/{request_id}
Cancel an outgoing payment request (only while status is pending).
Required permission: wallet:send
Example: DELETE /api/v1/requests/abc123def456
Response:
{
"success": true,
"data": null,
"message": "Payment request cancelled"
}
System Endpoints
GET /api/v1/system/status
Get system health status.
Permission: system:read
Response:
{
"success": true,
"data": {
"status": "operational",
"version": "0.1.5-alpha",
"environment": "production",
"database": "healthy",
"processors": {
"p2p": true,
"transaction": true,
"cleanup": true
},
"update": {
"available": true,
"current_version": "0.1.5-alpha",
"latest_version": "0.1.6-alpha",
"last_checked": "2026-03-31T02:00:00+00:00",
"source": "docker-hub",
"error": null
},
"analytics": {
"enabled": false,
"consent_pending": true,
"last_submitted": null,
"opt_in_at": null
},
"timestamp": "2026-03-31T12:00:00+00:00"
},
"request_id": "req_abc123"
}
Fields:
status: Always"operational"when system is runningdatabase:"healthy"or"unhealthy"based on database connectivityprocessors: Boolean flags indicating if processor PID files existupdate.available: Whether a newer version exists on Docker Hubupdate.current_version: The running node’s versionupdate.latest_version: The latest version found (null if check hasn’t run)update.last_checked: ISO 8601 timestamp of last check (null if never checked)update.source:"docker-hub"or"github"(null if not checked)update.error: Error message if the last check failed (null on success)analytics.enabled: Whether anonymous usage analytics are enabledanalytics.consent_pending: Whether the user has not yet been asked for analytics consentanalytics.last_submitted: ISO 8601 timestamp of last analytics submission (null if never submitted)analytics.opt_in_at: ISO 8601 timestamp of the most recent off→on transition ofanalytics_enabled(null if analytics have never been enabled). Legacy nodes whose opt-in predates this field are backfilled to “now” on the first cron run after upgrade. Bounds the heartbeat rollup window so no data from before consent is ever reported
GET /api/v1/system/metrics
Get system metrics.
Permission: system:read
Response:
{
"success": true,
"data": {
"transactions": {
"total": 1500,
"by_type": {
"send": 750,
"receive": 745,
"fee": 5
}
},
"contacts": {
"total_accepted": 25
},
"p2p": {
"queued": 3
},
"uptime": "5d 12h 30m",
"memory_usage": 52428800,
"timestamp": "2026-01-24T12:00:00+00:00"
},
"request_id": "req_abc123"
}
Fields:
transactions.by_type: Count of transactions grouped by type (send, receive, fee, etc.)contacts.total_accepted: Number of mutually accepted contactsp2p.queued: Number of P2P relay messages waiting to be processeduptime: Formatted uptime string (days, hours, minutes)memory_usage: Current PHP memory usage in bytes
GET /api/v1/system/settings
Get system settings.
Permission: system:read
Response:
{
"success": true,
"data": {
"settings": {
"name": "Alice",
"default_currency": "VWL",
"minimum_fee_amount": 0,
"default_fee_percent": 0,
"maximum_fee_percent": 5.0,
"default_credit_limit": 100.00,
"max_p2p_level": 3,
"p2p_expiration_seconds": 3600,
"direct_tx_expiration": 0,
"max_output_lines": 100,
"default_transport_mode": "http",
"hostname": "http://alice",
"hostname_secure": "https://alice",
"trusted_proxies": "",
"auto_backup_enabled": true,
"auto_accept_transaction": true,
"hop_budget_randomized": true,
"contact_status_enabled": true,
"contact_status_sync_on_ping": true,
"auto_chain_drop_propose": true,
"auto_chain_drop_accept": false,
"auto_chain_drop_accept_guard": true,
"auto_accept_restored_contact": true,
"api_enabled": true,
"api_cors_allowed_origins": "",
"rate_limit_enabled": true,
"backup_retention_count": 3,
"backup_cron_hour": 0,
"backup_cron_minute": 0,
"log_level": "INFO",
"log_max_entries": 100,
"cleanup_delivery_retention_days": 30,
"cleanup_dlq_retention_days": 90,
"cleanup_held_tx_retention_days": 7,
"cleanup_rp2p_retention_days": 30,
"cleanup_metrics_retention_days": 90,
"payment_requests_archive_retention_days": 180,
"payment_requests_archive_batch_size": 500,
"transactions_archive_retention_days": 30,
"transactions_archive_batch_size": 500,
"p2p_rate_limit_per_minute": 60,
"rate_limit_max_attempts": 10,
"rate_limit_window_seconds": 60,
"rate_limit_block_seconds": 300,
"http_transport_timeout_seconds": 15,
"tor_transport_timeout_seconds": 30,
"tor_circuit_max_failures": 3,
"tor_circuit_cooldown_seconds": 300,
"tor_failure_transport_fallback": true,
"tor_fallback_require_encrypted": true,
"display_date_format": "Y-m-d H:i:s.u",
"allowed_currencies": ["VWL", "EUR"]
}
}
}
Fields:
name: Display name for this nodedefault_currency: Default currency code for transactions (e.g., “VWL”)minimum_fee_amount: Minimum fee amount for transactions (0 = free relaying)default_fee_percent: Default fee percentage for new contactsmaximum_fee_percent: Maximum allowed fee percentagedefault_credit_limit: Default credit limit for new contactsmax_p2p_level: Maximum P2P relay depth levelp2p_expiration_seconds: Time in seconds before P2P requests expiredirect_tx_expiration: Direct (non-P2P) transaction delivery timeout in seconds (0 = no expiry)max_output_lines: Maximum output lines for CLI commandsdefault_transport_mode: Default transport protocol (“http”, “https”, or “tor”)hostname: HTTP hostname of the node (e.g., “http://alice”)hostname_secure: HTTPS hostname of the node (e.g., “https://alice”)trusted_proxies: Trusted proxy IPs for header forwarding (comma-separated, empty = none)auto_backup_enabled: Whether daily automatic database backup is enabledauto_accept_transaction: Whether to auto-accept P2P transactions when route foundhop_budget_randomized: Whether P2P hop budget is randomized via geometric distribution (disable for maximum routing depth in sparse networks)contact_status_enabled: Whether contact status tracking is enabledcontact_status_sync_on_ping: Whether to sync contact status during pingauto_chain_drop_propose: Whether to auto-propose tx-drop operationsauto_chain_drop_accept: Whether to auto-accept tx-drop proposalsauto_chain_drop_accept_guard: Whether to run balance guard before auto-acceptingauto_accept_restored_contact: Whether to auto-accept restored contacts on wallet restore when transaction history proves prior relationshipapi_enabled: Whether the REST API endpoint is enabledapi_cors_allowed_origins: Allowed CORS origins for API (empty = none)rate_limit_enabled: Whether rate limiting is active (configurable via CLI/API only — not exposed in GUI)backup_retention_count: Number of backup files to retain (min 1)backup_cron_hour: Backup schedule hour in UTC (0-23)backup_cron_minute: Backup schedule minute (0-59)log_level: Minimum log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)log_max_entries: Maximum log entries to keep (min 10)cleanup_delivery_retention_days: Days to retain delivery records (min 1)cleanup_dlq_retention_days: Days to retain dead letter queue entries (min 1)cleanup_held_tx_retention_days: Days to retain held transactions (min 1)cleanup_rp2p_retention_days: Days to retain P2P routing records (min 1)cleanup_metrics_retention_days: Days to retain metrics data (min 1)payment_requests_archive_retention_days: Days resolved (non-pending) payment requests stay in the livepayment_requeststable before moving topayment_requests_archive(min 1). Archived rows stay queryable — this is a move, not a deletepayment_requests_archive_batch_size: Max rows the nightly archival cron moves per run (min 1)transactions_archive_retention_days: Days completed transactions stay in the livetransactionstable before moving totransactions_archive(min 1). Archival is additionally gated per bilateral pair on a gap-free chain-integrity check — pairs with a detected gap are skipped, not archived. This is a move, not a deletetransactions_archive_batch_size: Max rows the transactions archival cron moves per batch per bilateral pair (min 1)p2p_rate_limit_per_minute: Maximum P2P requests per minute (min 1)rate_limit_max_attempts: Max attempts before rate limit triggers (min 1)rate_limit_window_seconds: Rate limit time window in seconds (min 1)rate_limit_block_seconds: Block duration after limit exceeded in seconds (min 1)http_transport_timeout_seconds: HTTP transport timeout (5-120 seconds)tor_transport_timeout_seconds: Tor transport timeout (10-300 seconds)tor_circuit_max_failures: Consecutive Tor failures before cooldown (1-10)tor_circuit_cooldown_seconds: Cooldown duration after max failures (60-3600 seconds)tor_failure_transport_fallback: Fall back to HTTP/HTTPS when Tor delivery failstor_fallback_require_encrypted: Only fall back to HTTPS, never plain HTTPdisplay_date_format: PHP date format string for timestampsallowed_currencies: List of allowed currency codes
PUT /api/v1/system/settings
Update system settings.
Permission: system:write (or admin)
Request Body:
{
"default_fee": 1.5,
"default_credit_limit": 200.00,
"hostname": "http://mynode"
}
| Field | Type | Description |
|---|---|---|
default_fee |
number | Default fee percentage for new contacts |
default_credit_limit |
number | Default credit limit for new contacts |
default_currency |
string | Default currency code (e.g., VWL) |
min_fee |
number | Minimum fee amount (0 = free relaying) |
max_fee |
number | Maximum fee percentage |
max_p2p_level |
int | Maximum P2P relay depth |
p2p_expiration |
int | P2P request expiration in seconds |
direct_tx_expiration |
int | Direct TX delivery timeout in seconds (0 = no expiry) |
max_output |
int | Maximum CLI output lines (0 = unlimited) |
default_transport_mode |
string | Default transport protocol (http, https, tor) |
auto_backup_enabled |
boolean | Enable/disable automatic backups |
trusted_proxies |
string | Trusted proxy IPs (comma-separated, empty = none) |
hostname |
string | Node hostname (triggers SSL cert regeneration) |
name |
string | Node display name |
allowed_currencies |
string | Allowed currencies (comma-separated, e.g., “VWL,EUR”) |
auto_reject_unknown_currency |
boolean | Auto-reject contact requests with currencies not in allowed list |
hop_budget_randomized |
boolean | Randomize P2P hop depth (disable for max reachability) |
contact_status_enabled |
boolean | Enable/disable contact status tracking |
contact_status_sync_on_ping |
boolean | Sync status during ping operations |
auto_chain_drop_propose |
boolean | Auto-propose tx-drop operations |
auto_chain_drop_accept |
boolean | Auto-accept tx-drop proposals |
auto_chain_drop_accept_guard |
boolean | Balance guard for auto-accept |
auto_accept_restored_contact |
boolean | Auto-accept restored contacts on wallet restore |
analytics_enabled |
boolean | Enable/disable anonymous usage analytics (opt-in, default off) |
api_enabled |
boolean | Enable/disable REST API endpoint |
api_cors_allowed_origins |
string | Allowed CORS origins (empty = none) |
rate_limit_enabled |
boolean | Enable/disable rate limiting (CLI/API only — not exposed in GUI) |
backup_retention_count |
int | Backup files to retain (min 1) |
backup_cron_hour |
int | Backup schedule hour UTC (0-23) |
backup_cron_minute |
int | Backup schedule minute (0-59) |
log_level |
string | Min log level: DEBUG, INFO, WARNING, ERROR, CRITICAL |
log_max_entries |
int | Max log entries to keep (min 10) |
cleanup_delivery_retention_days |
int | Delivery record retention days (min 1) |
cleanup_dlq_retention_days |
int | DLQ entry retention days (min 1) |
cleanup_held_tx_retention_days |
int | Held transaction retention days (min 1) |
cleanup_rp2p_retention_days |
int | P2P routing record retention days (min 1) |
cleanup_metrics_retention_days |
int | Metrics data retention days (min 1) |
payment_requests_archive_retention_days |
int | Days before resolved payment requests move to payment_requests_archive (min 1). Move, not delete |
payment_requests_archive_batch_size |
int | Max rows moved per archival cron run (min 1) |
transactions_archive_retention_days |
int | Days before completed transactions move to transactions_archive (min 1). Archival gated per bilateral pair on chain-integrity verify |
transactions_archive_batch_size |
int | Max rows moved per batch per bilateral pair (min 1) |
p2p_rate_limit_per_minute |
int | Max P2P requests per minute (min 1) |
rate_limit_max_attempts |
int | Attempts before rate limit triggers (min 1) |
rate_limit_window_seconds |
int | Rate limit time window seconds (min 1) |
rate_limit_block_seconds |
int | Block duration after limit exceeded (min 1) |
http_transport_timeout_seconds |
int | HTTP timeout (5-120 seconds) |
tor_transport_timeout_seconds |
int | Tor timeout (10-300 seconds) |
tor_circuit_max_failures |
int | Consecutive Tor failures before cooldown (1-10) |
tor_circuit_cooldown_seconds |
int | Cooldown after max failures (60-3600 seconds) |
tor_failure_transport_fallback |
boolean | Fall back to HTTP/HTTPS when Tor fails |
tor_fallback_require_encrypted |
boolean | Only fall back to HTTPS, never HTTP |
sync_chunk_size |
int | Transactions per sync chunk (10-500) |
sync_max_chunks |
int | Max sync chunks per cycle (10-1000) |
held_tx_sync_timeout_seconds |
int | Held tx sync timeout (30-299 seconds) |
display_date_format |
string | PHP date format string |
All fields are optional. Only provided fields will be updated. Unknown fields return warnings.
Response:
{
"success": true,
"data": {
"message": "Settings updated successfully",
"updated": {
"default_fee": 1.5,
"default_credit_limit": 200.00,
"hostname": "http://mynode",
"hostname_secure": "https://mynode"
}
}
}
Partial Success Response (some fields valid, some invalid):
{
"success": true,
"data": {
"message": "Settings updated successfully",
"updated": {
"default_fee": 1.5
},
"warnings": [
"Unknown setting: invalid_key"
]
}
}
Notes:
- Changing
hostnameautomatically deriveshostname_secureand regenerates the SSL certificate - Boolean fields accept:
true/false,"true"/"false","1"/"0","on"/"off","yes"/"no"
POST /api/v1/system/update-check
Trigger a manual update check against Docker Hub and GitHub Releases. Bypasses the 24-hour cache.
Permission: system:read
Response:
{
"success": true,
"data": {
"available": true,
"current_version": "0.1.4-alpha",
"latest_version": "0.1.5-alpha",
"last_checked": "2026-03-31T20:53:57+00:00",
"source": "docker-hub",
"error": null
}
}
| Field | Type | Description |
|---|---|---|
available |
boolean | Whether a newer version exists |
current_version |
string | Currently running version |
latest_version |
string|null | Latest version found (null if check failed) |
last_checked |
string | ISO 8601 timestamp of this check |
source |
string|null | docker-hub or github |
error |
string|null | Error message if both sources failed |
Notes:
- Checks Docker Hub first (primary), falls back to GitHub Releases
- Returns
502if both sources are unreachable - The result is cached — subsequent
GET /api/v1/system/statuscalls include the cached update status without triggering a new check
POST /api/v1/system/sync
Trigger a sync operation to synchronize data with contacts.
Permission: system:write (or admin)
Request Body (optional):
{
"type": "contacts"
}
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | No | Sync type: contacts, transactions, balances, or omit for all |
Response:
{
"success": true,
"data": {
"message": "Sync completed",
"type": "all",
"results": null
}
}
POST /api/v1/system/shutdown
Shutdown background processors. The API remains responsive; only background workers (P2P, transaction processor, etc.) are terminated.
Permission: system:write (or admin)
Response:
{
"success": true,
"data": {
"message": "Processors shutdown initiated",
"processes_terminated": 3,
"pid_files_cleaned": 3
}
}
Fields:
processes_terminated: Number of processes that received SIGTERMpid_files_cleaned: Number of PID files removed
Notes:
- Creates a shutdown flag at
/tmp/eiou_shutdown.flagto prevent the watchdog from restarting processors - The API server itself is not affected and continues to serve requests
POST /api/v1/system/start
Start background processors by removing the shutdown flag. The watchdog process will detect the flag removal and restart processors automatically.
Permission: system:write (or admin)
Response (processors were stopped):
{
"success": true,
"data": {
"message": "Processor restart initiated",
"shutdown_flag_removed": true,
"action": "watchdog_will_restart"
}
}
Response (processors already running):
{
"success": true,
"data": {
"message": "Processors are already running",
"shutdown_flag_removed": false,
"action": "none"
}
}
POST /api/v1/system/restart
Request a full in-place node restart — both the background processors and the PHP-FPM workers. Required after toggling plugins (or any other state bound at boot) so event subscriptions rebind without a container reboot.
The API runs as www-data and cannot signal the root-owned PHP-FPM master directly, so this endpoint writes a request marker that the root-side poller in startup.sh picks up within ~2 seconds and turns into the equivalent of eiou restart. Rate-limited to one restart per 10 seconds at the poller; rapid duplicate calls return success but only the first is acted on.
Permission: system:write (or admin)
Response (success):
{
"success": true,
"data": {
"message": "Restart requested. The node will respawn its workers within a few seconds.",
"expected_restart_within_seconds": 5
}
}
Errors:
| Code | Status | When |
|---|---|---|
request_write_failed |
500 | The request marker could not be written |
restart_error |
500 | Unhandled exception while requesting the restart |
POST /api/v1/system/retire
Retire (decommission) this node: stop its message processors and persist a flag so the node skips processors and Tor hidden-service publication on every boot until it is un-retired. Use this when moving a wallet identity to another node, since the seed deterministically derives the same .onion and two live nodes would fight over one hidden service and split history across two databases.
The API runs as www-data and cannot tear down the Tor hidden service itself, so it stops the processors and drops a marker the root-side poller acts on (it removes the HS key and reloads Tor). A plain restart would not, since restart is in-place and never re-runs startup.sh.
Permission: system:write (or admin)
Request Body:
{
"confirm": true
}
| Field | Type | Required | Description |
|---|---|---|---|
confirm |
boolean | Yes | Must be true. Retiring tears down the .onion, so a node reached only over Tor must be reactivated via HTTP/HTTPS or the CLI |
Response (success):
{
"success": true,
"data": {
"message": "Node retired. Processors stopped and hidden-service teardown requested.",
"retired": true,
"processes_terminated": 4,
"tor_access_warning": "The .onion is being torn down — if you reach this node only over Tor, use HTTP/HTTPS or the CLI to unretire."
}
}
Errors:
| Code | Status | When |
|---|---|---|
confirmation_required |
400 | confirm was not true |
POST /api/v1/system/unretire
Reverse a retire: reactivate this node and republish its Tor hidden service. Only safe when no other node is currently running this wallet identity, or the two will fight over the hidden service and corrupt transaction history.
Permission: system:write (or admin)
Request Body:
{
"confirm": true
}
| Field | Type | Required | Description |
|---|---|---|---|
confirm |
boolean | Yes | Must be true. Acknowledges that no other node is running this identity |
Response (success):
{
"success": true,
"data": {
"message": "Node un-retired. The wallet hidden service is being republished within a few seconds and processors resume within ~30s.",
"retired": false
}
}
Errors:
| Code | Status | When |
|---|---|---|
confirmation_required |
400 | confirm was not true |
GET /api/v1/system/debug-report
Download a debug report as JSON. Includes system info, debug table entries, application logs, PHP errors, and nginx errors.
Permission: system:read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
full |
boolean | false |
Include full log history (default: last 50 lines per log) |
description |
string | "" |
Optional issue description included in the report |
Response:
{
"success": true,
"data": {
"report": { "..." },
"report_type": "limited",
"debug_entries_count": 42
}
}
Notes:
- Same report format as the CLI
eiou report debugand the GUI Debug Report (all useDebugReportService) - Limited mode includes last 50 lines of each log file; full mode includes up to 5MB per log
- Reports do not contain private keys, seed phrases, or authentication codes
POST /api/v1/system/debug-report
Generate a debug report, scrub sensitive data (addresses, keys, IPs), and submit it to the support endpoint via Tor.
Permission: system:read
Request Body:
{
"description": "login page crash after update",
"full": false
}
| Field | Type | Required | Description |
|---|---|---|---|
description |
string | No | Issue description (max 500 chars) |
full |
boolean | No | Include full log history (default: false) |
Response (success):
{
"success": true,
"data": {
"submitted": true,
"key": "rpt_abc123",
"report_type": "limited"
}
}
Error Responses:
| Code | Status | Cause |
|---|---|---|
debug_report_submit_failed |
502 | Tor submission failed or server rejected |
debug_report_error |
500 | Report generation failed |
Notes:
- Sensitive data (onion addresses, public keys, IPs, URLs) is scrubbed before submission
- Rate-limited to 3 submissions per day (client-side)
- Payloads over 4.5MB are automatically trimmed (debug entries first, then log fields)
- If still over 5MB after trimming, returns an error suggesting manual download instead
Tx Drop Endpoints
Tx drops allow mutually dropping one or more missing transactions from the shared chain with a contact and re-wiring the chain around the drop, when both sides have a mutual gap that sync and backup recovery cannot repair. A single tx drop spans one or more consecutive missing transactions; non-consecutive gaps require a separate proposal per run. Auto-propose is controlled by EIOU_AUTO_CHAIN_DROP_PROPOSE (default: true). Auto-accept is controlled by EIOU_AUTO_CHAIN_DROP_ACCEPT (default: false). The balance guard (EIOU_AUTO_CHAIN_DROP_ACCEPT_GUARD, default: true) can be disabled for unconditional auto-accept.
GET /api/v1/chaindrop
List tx drop proposals.
Permission: wallet:read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
contact |
string | null | Filter by contact name or address |
Response:
{
"success": true,
"data": {
"proposals": [
{
"proposal_id": "cd_abc123",
"contact_pubkey_hash": "def456...",
"status": "pending",
"created_at": "2026-01-24T12:00:00Z"
}
],
"count": 1
}
}
Notes:
- Without
contactfilter, returns all incoming pending proposals - With
contactfilter, returns all proposals (any status) for that contact
POST /api/v1/chaindrop/propose
Propose a tx drop with a contact. This initiates the process of mutually dropping one or more missing transactions and re-wiring the chain around the drop.
Permission: wallet:send
Request Body:
{
"contact": "Bob"
}
| Field | Type | Required | Description |
|---|---|---|---|
contact |
string | Yes | Contact name or address (also accepts address field name) |
Response (201 Created):
{
"success": true,
"data": {
"message": "Chain drop proposed successfully",
"proposal_id": "cd_abc123",
"missing_txid": "tx_missing...",
"broken_txid": "tx_broken..."
}
}
Fields:
proposal_id: Unique identifier for the proposalmissing_txid: The transaction ID that triggered the chain integrity issue (if applicable)broken_txid: The transaction ID where the chain break was detected (if applicable)
POST /api/v1/chaindrop/accept
Accept a pending tx drop proposal. Irreversible chain rewrite — drops the missing transactions, re-signs surrounding transactions on both sides, recalculates balances. Asymmetric with propose (which is just a sent request, non-destructive on this node) and reject (declines, leaves gap).
The server-side auto-accept policy (env EIOU_AUTO_CHAIN_DROP_ACCEPT) handles the routine case automatically; this endpoint is for manual operator override.
Permission: admin (not wallet:send, because of the chain-rewrite blast radius)
Request Body:
{
"proposal_id": "cd_abc123"
}
| Field | Type | Required | Description |
|---|---|---|---|
proposal_id |
string | Yes | ID of the proposal to accept |
Response:
{
"success": true,
"data": {
"message": "Chain drop proposal accepted",
"proposal_id": "cd_abc123"
}
}
POST /api/v1/chaindrop/reject
Reject a pending tx drop proposal.
Permission: wallet:send
Request Body:
{
"proposal_id": "cd_abc123"
}
| Field | Type | Required | Description |
|---|---|---|---|
proposal_id |
string | Yes | ID of the proposal to reject |
Response:
{
"success": true,
"data": {
"message": "Chain drop proposal rejected",
"proposal_id": "cd_abc123"
}
}
P2P Approval Endpoints
Manage P2P transactions awaiting manual approval. These endpoints are used when autoAcceptTransaction is disabled in wallet settings.
Routing Mode Behavior
How P2P transactions behave depends on the routing mode and autoAcceptTransaction setting:
| Routing Mode | autoAcceptTransaction |
Behavior |
|---|---|---|
Fast (--fast / default) |
ON (default) | First route response is auto-sent immediately |
| Fast | OFF | First route response is held for approval — user sees 1 route to accept or reject |
Best-fee (--best) |
ON | All route responses are collected, cheapest is auto-sent |
| Best-fee | OFF | All route responses are collected and listed — user picks which route to use |
| Best-fee + Tor destination | OFF | Internally uses fast mode (Tor requires single-hop); user sees 1 route to accept or reject |
When a transaction enters awaiting_approval status:
- Late-arriving route candidates are still accepted and added to the candidate list
- The transaction will eventually expire through normal cleanup if not acted upon
- Cancel notifications from relay nodes are tracked but do not auto-trigger route selection
GET /api/v1/p2p
List all P2P transactions awaiting approval.
Permission: wallet:read
Response:
{
"success": true,
"data": {
"transactions": [
{
"hash": "abc123def456",
"amount": 1000,
"currency": "VWL",
"destination_address": "http://bob:8080",
"my_fee_amount": 10,
"rp2p_amount": 1010,
"fast": 1,
"candidate_count": 0,
"created_at": "2026-02-26 10:00:00"
}
],
"count": 1
}
}
Fields:
| Field | Type | Description |
|---|---|---|
hash |
string | P2P transaction hash |
amount |
int | Original send amount (minor units) |
currency |
string | Currency code |
destination_address |
string | Recipient address |
my_fee_amount |
int | This node’s fee amount |
rp2p_amount |
int|null | Total cost from RP2P response (null if pending) |
fast |
int | 1 = fast mode, 0 = best-fee mode |
candidate_count |
int | Number of route candidates available |
created_at |
string | Creation timestamp |
GET /api/v1/p2p/candidates/{hash}
Get route candidates for a specific P2P transaction.
Permission: wallet:read
Response:
{
"success": true,
"data": {
"hash": "abc123def456",
"amount": 1000,
"currency": "VWL",
"fast": 0,
"candidates": [
{
"id": 1,
"hash": "abc123def456",
"sender_address": "http://relay1:8080",
"amount": 1020,
"currency": "VWL",
"fee_amount": 20,
"sender_public_key": "...",
"sender_signature": "...",
"time": 123456,
"created_at": "2026-02-26 10:01:00"
}
],
"rp2p": null
}
}
Notes:
candidatescontains best-fee mode route options (ordered by fee, lowest first)rp2pcontains the single RP2P response for fast mode (null if none)- Returns 404 if hash not found or not in
awaiting_approvalstatus
POST /api/v1/p2p/approve
Approve a P2P transaction and send it via the selected route.
Permission: wallet:send
Request Body:
{
"hash": "abc123def456",
"candidate_id": 5
}
| Field | Type | Required | Description |
|---|---|---|---|
hash |
string | Yes | P2P transaction hash |
candidate_id |
int | No | ID of the candidate to use (from candidates endpoint) |
Response:
{
"success": true,
"data": {
"message": "P2P transaction approved and sent",
"hash": "abc123def456",
"candidate_id": 5
}
}
Behavior:
- With
candidate_id: Uses the specified candidate route - Without
candidate_id+ single route: Auto-selects the available route (fast mode) - Without
candidate_id+ multiple candidates: Returns 400 error with codecandidate_selection_required
POST /api/v1/p2p/reject
Reject a P2P transaction, cancel it, and propagate the cancellation upstream.
Permission: wallet:send
Request Body:
{
"hash": "abc123def456"
}
| Field | Type | Required | Description |
|---|---|---|---|
hash |
string | Yes | P2P transaction hash |
Response:
{
"success": true,
"data": {
"message": "P2P transaction rejected and cancelled",
"hash": "abc123def456"
}
}
Notes:
- Sets the P2P status to
cancelled - Sends cancel notification to upstream relay nodes
- Cleans up any remaining route candidates
Backup Endpoints
Manage encrypted database backups.
GET /api/v1/backup/status
Get backup system status and settings.
Permission: backup:read or admin
Response:
{
"success": true,
"data": {
"enabled": true,
"backup_count": 3,
"retention_count": 3,
"last_backup": "2026-01-24T03:00:00+00:00",
"last_backup_file": "backup_20260124_030000.eiou.enc",
"backup_directory": "/var/lib/eiou/backups",
"next_scheduled": "2026-01-25T03:00:00+00:00"
}
}
Fields:
enabled: Whether automatic daily backups are enabledbackup_count: Number of existing backup filesretention_count: Maximum backups to retain (default: 3)next_scheduled: Next scheduled backup time (null if disabled)
GET /api/v1/backup/list
List all available backup files.
Permission: backup:read or admin
Response:
{
"success": true,
"data": {
"backups": [
{
"filename": "backup_20260124_030000.eiou.enc",
"size": 524288,
"size_human": "512 KB",
"created_at": "2026-01-24T03:00:00+00:00"
},
{
"filename": "backup_20260123_030000.eiou.enc",
"size": 520192,
"size_human": "508 KB",
"created_at": "2026-01-23T03:00:00+00:00"
}
],
"count": 2
}
}
POST /api/v1/backup/create
Create a new encrypted backup.
Permission: backup:write or admin
Request Body (optional):
{
"name": "pre_upgrade_backup"
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | No | Custom name for backup (alphanumeric, underscore, hyphen only) |
Response (201 Created):
{
"success": true,
"data": {
"message": "Backup created successfully",
"filename": "pre_upgrade_backup.eiou.enc",
"size": 524288,
"path": "/var/lib/eiou/backups/pre_upgrade_backup.eiou.enc"
}
}
POST /api/v1/backup/restore
Online database restore is intentionally disabled. This endpoint does not act
on its request body and always returns offline_restore_required/HTTP 422. The
HTTP worker cannot import a backup. Start a new-volume candidate with
EIOU_BOOT_RECOVERY_BACKUP=<filename> so recovery runs before serving and
scheduled processes start.
Permission: backup:write or admin
Request Body: Ignored. The former shape is shown only so older clients can recognize the cutoff response:
{
"filename": "backup_20260124_030000.eiou.enc",
"confirm": true
}
| Field | Type | Required | Description |
|---|---|---|---|
filename |
string | No | Ignored |
confirm |
boolean | No | Ignored |
Warning: Never enable offline restore on a serving node.
Response:
{
"success": false,
"error": {
"code": "offline_restore_required",
"message": "Backup restore is available only during boot recovery before services start"
}
}
Error Response (confirmation required):
{
"success": false,
"error": {
"code": "confirmation_required",
"message": "Must set confirm: true to restore backup. This will overwrite all current database data!"
}
}
POST /api/v1/backup/verify
Verify backup file integrity and decryption.
Permission: backup:read or admin
Request Body:
{
"filename": "backup_20260124_030000.eiou.enc"
}
Response:
{
"success": true,
"data": {
"filename": "backup_20260124_030000.eiou.enc",
"valid": true,
"version": "1.0",
"created_at": "2026-01-24T03:00:00+00:00"
}
}
Fields:
valid:trueif backup can be decrypted and contains valid SQLversion: Backup format versioncreated_at: Timestamp when backup was created
DELETE /api/v1/backup/:filename
Delete a backup file.
Permission: backup:write or admin
Response:
{
"success": true,
"data": {
"message": "Backup deleted successfully",
"filename": "backup_20260124_030000.eiou.enc"
}
}
POST /api/v1/backup/enable
Enable automatic daily backups.
Permission: backup:write or admin
Response:
{
"success": true,
"data": {
"message": "Automatic backups enabled",
"enabled": true
}
}
POST /api/v1/backup/disable
Disable automatic daily backups.
Permission: backup:write or admin
Response:
{
"success": true,
"data": {
"message": "Automatic backups disabled",
"enabled": false
}
}
POST /api/v1/backup/cleanup
Remove old backup files, keeping only the most recent (default: 3).
Permission: backup:write or admin
Response:
{
"success": true,
"data": {
"message": "Backup cleanup completed",
"deleted_count": 2,
"deleted_files": [
"backup_20260120_030000.eiou.enc",
"backup_20260119_030000.eiou.enc"
]
}
}
Export Endpoints
Stream a portion of the wallet database as CSV or NDJSON for accounting, audit, or external-tool consumption. Distinct from /api/v1/backup/*: backups are full encrypted snapshots intended for disaster recovery; exports are selective, plaintext, and human-friendly. The pipeline streams from the database one row at a time — PHP-level memory stays constant as wallet size grows.
Permissions: wallet:read (same permission as /api/v1/wallet/transactions — operators that can list transactions can export them).
GET /api/v1/export/transactions
Stream wallet transactions (live + archive) ordered oldest-first.
Query parameters:
| Param | Values | Description |
|---|---|---|
format |
csv, json |
Output format (default csv). |
view |
slim, full |
Field set (default slim). See “Field sets” below. |
contact_pubkey_hash |
hex string | Restrict to bilateral transactions with this contact. |
status |
enum | Filter by pending, sending, sent, accepted, rejected, cancelled, completed, or failed. |
type |
enum | Filter by direction: sent, received, relay. |
from |
YYYY-MM-DD |
Inclusive lower bound on transaction date (UTC). |
to |
YYYY-MM-DD |
Inclusive upper bound on transaction date (UTC). |
Response headers:
| Header | Value |
|---|---|
Content-Type |
text/csv; charset=utf-8 for CSV, application/x-ndjson for JSON |
Content-Disposition |
attachment; filename="eiou-transactions[-contact-<slug>]-<wallet-slug>-<YYYY-MM-DD>.<ext>" |
X-Eiou-Export-View |
Echo of the view used (slim or full) |
Cache-Control |
no-store |
Field sets:
| View | Columns |
|---|---|
slim |
txid, date_utc, direction, status, counterparty_name, counterparty_pubkey_hash, currency, amount_decimal, amount_whole, amount_frac, description |
full |
slim + routing_marker, tx_type, sender_address, receiver_address, sender_signature, recipient_signature, signed_message_content, previous_txid, initial_sender_address, end_recipient_address, recovery_count, expires_at, source_table |
routing_marker (full view only) is the DB’s memo column, exposed under a clearer name. It carries internal markers like standard or contact rather than operator-facing notes — what users would call “the memo on a transaction” lives in description.
The description column is display-sanitized (control, bidi, and zero-width characters are stripped and the value is byte-clamped) so it renders safely in spreadsheets and terminals. It is therefore not a byte-for-byte copy of what was signed: a description containing a zero-width-joiner emoji or right-to-left text will differ from the signed bytes. For signature verification or exact-byte audit, use the signed_message_content column (full view), which holds the verbatim signed payload.
CSV format: UTF-8 with leading BOM (Excel-friendly), comma delimiter, RFC 4180 double-quote escaping (no PHP-style backslash escape), CRLF row terminators.
JSON format: Newline-delimited JSON (NDJSON) — one JSON object per line, no enclosing array, no trailing newline-pair. JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE.
Amount columns: All three are present in both views — amount_decimal (string, 8 fractional digits) for spreadsheet users; amount_whole and amount_frac (integers) for precision-sensitive tools that need to reconstruct the value exactly.
Date format: ISO 8601 UTC, second-precision: YYYY-MM-DDTHH:MM:SSZ.
Counterparty resolution: Names come from the accepted-contacts table, cached once at the start of the export so per-row JOINs are avoided. Counterparties not in the address book have an empty counterparty_name but a populated counterparty_pubkey_hash.
Mid-stream error contract: By the time a streaming source throws, the response has already been HTTP 200-streamed past the headers. The export emits a format-appropriate sentinel before returning:
- CSV: a final row beginning
#ERROR <message>(visibly distinct from data rows). - NDJSON: a final line
{"_error": "...", "_partial": true}.
Clients that check trailing content can detect truncation and retry.
Examples:
# Slim CSV of all transactions
curl -H "Authorization: ..." \
"https://node.example/api/v1/export/transactions" -o my-history.csv
# Full audit JSON of transactions with Alice from this year
curl -H "Authorization: ..." \
"https://node.example/api/v1/export/transactions?format=json&view=full&contact_pubkey_hash=pkh-alice&from=2026-01-01" \
-o alice-audit.ndjson
GET /api/v1/export/payment_requests
Stream payment requests (live + archive, both incoming and outgoing) ordered oldest-first.
Query parameters: same as /export/transactions except type is replaced by direction:
| Param | Values | Description |
|---|---|---|
format |
csv, json |
Output format (default csv). |
view |
slim, full |
Field set (default slim). |
contact_pubkey_hash |
hex string | Restrict to the bilateral subset involving this contact. |
status |
enum | pending, sending, approved, failed, declined, cancelled, expired. |
direction |
enum | incoming (someone is asking you to pay) or outgoing (you are asking someone to pay). |
from, to |
YYYY-MM-DD |
Inclusive UTC bounds on created_at. |
Response headers: identical shape to /export/transactions (Content-Type csv or x-ndjson, Content-Disposition attachment with eiou-payment_requests-<wallet>-<date>.{csv,ndjson}, X-Eiou-Export-View echo, Cache-Control: no-store).
Field sets:
| View | Columns |
|---|---|
slim |
request_id, date_utc, direction, status, counterparty_name, counterparty_pubkey_hash, currency, amount_decimal, amount_whole, amount_frac, description |
full |
slim + requester_pubkey_hash, recipient_pubkey_hash, requester_address, expires_at, responded_at, resulting_txid, signed_message_content, stored_contact_name, source_table |
stored_contact_name (full view only) is the snapshot of the counterparty’s display name taken when the request was created. It survives the contact being later deleted or renamed — useful for audit trails where the live address book has drifted from what the request actually carried.
Counterparty resolution: direction='incoming' (someone is asking you to pay) puts the requester on the counterparty_* columns; direction='outgoing' (you asked someone to pay) puts the recipient there. Mirrors the transactions export’s direction-aware behaviour so a merged CSV reads consistently.
GET /api/v1/export/contacts
Stream the wallet’s address book — one row per contact, sorted by display name.
Query parameters:
| Param | Values | Description |
|---|---|---|
format |
csv, json |
Output format (default csv). |
view |
slim, full |
Field set (default slim). |
status |
accepted, pending, blocked |
Single status filter; omit for every status. |
No contact_pubkey_hash filter — filtering contacts to one contact would always be a single-row export, not useful.
Field sets:
| View | Columns |
|---|---|
slim |
name, pubkey_hash, status, online_status, created_at_utc, last_ping_at_utc |
full |
slim + tor_address, https_address, http_address, valid_chain, remote_version, contact_id, pubkey |
valid_chain renders as the string "true", "false", or empty (the underlying column is a TINYINT(1) nullable that’s NULL until the chain has been verified).
GET /api/v1/export/balances
Stream per-(contact, currency) balance rows. Default sort: currency ASC, then contact name ASC within each currency. No cross-currency summation — that math is nonsense when currencies differ.
Query parameters:
| Param | Values | Description |
|---|---|---|
format |
csv, json |
Output format (default csv). |
view |
slim, full |
Field set (default slim). |
currency |
string | Restrict to a single currency code. |
contact_pubkey_hash |
hex string | Restrict to a single contact. |
Field sets:
| View | Columns |
|---|---|
slim |
currency, contact_name, contact_pubkey_hash, received_decimal, sent_decimal, net_decimal |
full |
slim + received_whole, received_frac, sent_whole, sent_frac, net_whole, net_frac |
net_decimal may be negative (wallet owes this contact more than it received) and is rendered with a leading - when so. In the full view, net_frac is always non-negative and the sign is carried on net_whole, matching SplitAmount’s normalization convention.
Payback Methods Endpoints
Manage this node’s own payback methods — the settlement rails (bank wire, PayPal, Bitcoin, custom free-text, etc.) you offer contacts so they can settle debts they owe you. Every row is encrypted at rest per-row (AES-256-GCM keyed to the wallet); sensitive fields only leave the node via the explicit reveal endpoint or when a contact fetches them over the E2E fetch flow.
Permissions:
| Scope | Grants |
|---|---|
payback:read |
GET /api/v1/payback-methods, GET /api/v1/payback-methods/{id} — list and view with masked sensitive fields |
payback:write |
every mutation (POST, PUT, DELETE) and GET /api/v1/payback-methods/{id}/reveal — the reveal endpoint returns plaintext so it’s treated as a write-class operation |
admin |
everything above |
Types on the wire: 29 rail types ship in core — bank_wire (sub-rails sepa, faster_payments, ach, fednow, swift), custom (free-text key/value rows), twelve crypto rails (btc, evm, solana, tron, lightning, xrp, stellar, monero, utxo_alt, ton, cardano, algorand), and fifteen P2P / fintech rails (venmo, paypal, revolut, wise, cashapp, zelle, pix, upi, mobile_payment, alipay, wechat_pay, interac, exchange_p2p, mercadopago, paynow). Plugins can register further rails by implementing PaybackMethodTypeContract; core-reserved ids cannot be shadowed. See docs//docs/reference/plugins for the full catalog and authoring details.
GET /api/v1/payback-methods
List this node’s payback methods. Sensitive fields are returned as a short masked_display string; the full field values are only accessible via /reveal.
Permission: payback:read
Query Parameters:
| Param | Type | Description |
|---|---|---|
currency |
string | Filter to a single currency code (case-insensitive). If omitted, returns all currencies. |
all |
0 / 1 |
Set to 1 to also include disabled rows. Default 0 (enabled-only). |
Response:
{
"success": true,
"data": {
"methods": [
{
"method_id": "pbm_01HV6...",
"type": "bank_wire",
"label": "Chase checking",
"currency": "VWL",
"priority": 100,
"enabled": true,
"share_policy": "auto",
"settlement_min_unit": 1,
"settlement_min_unit_exponent": -2,
"masked_display": "••••4409",
"created_at": "2026-04-01T10:30:00Z",
"updated_at": "2026-04-01T10:30:00Z"
}
],
"count": 1
}
}
Rows are ordered by priority ASC, created_at DESC.
POST /api/v1/payback-methods
Create a new payback method.
Permission: payback:write
Request Body:
{
"type": "bank_wire",
"label": "Chase checking",
"currency": "VWL",
"fields": {
"rail": "ach",
"recipient_name": "Jane Doe",
"routing_number": "021000021",
"account_number": "1234567890",
"account_type": "checking"
},
"share_policy": "auto",
"priority": 100
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type |
string | Yes | — | One of the 29 core rail ids, or a plugin-registered type id |
label |
string | Yes | — | Human label (≤ 128 chars) — not encrypted, shown in lists |
currency |
string | Yes | — | ISO-4217 or declared asset code (canonicalized server-side: fiat uppercased, mixed-case tokens preserved) |
fields |
object | Yes | — | Type-specific fields. Shape depends on type / rail — see the relevant PaybackMethodTypeContract for the schema |
share_policy |
string | No | auto |
auto or never |
priority |
integer | No | 100 |
0–9999, lower = preferred when multiple methods match the same currency |
Response (201 Created):
{
"success": true,
"data": {
"method_id": "pbm_01HV6..."
}
}
Validation errors (400):
{
"success": false,
"error": "validation_failed",
"message": "Validation failed",
"data": {
"errors": [
{ "field": "fields.iban", "code": "iban_checksum", "message": "IBAN mod-97 checksum failed" }
]
}
}
fields shape by type
Each core rail expects its own field set. The catalog the GUI consumes
(GET / paybackMethodsList doesn’t expose it; PaybackMethodTypeValidator::getCatalog()
is the source of truth) declares the exact field list per rail; the
table below is the same data flattened for API consumers. Optional
fields are listed in parentheses. Multi-variant rails use a
discriminator field (italicised) that determines what the rest of the
payload must contain.
Bank (1 type, 5 sub-rails)
type |
fields |
|---|---|
bank_wire (sepa) |
{rail: "sepa", recipient_name, iban} |
bank_wire (faster_payments) |
{rail: "faster_payments", recipient_name, sort_code, account_number} |
bank_wire (ach|fednow) |
{rail, recipient_name, routing_number, account_number, account_type: "checking"|"savings"} |
bank_wire (swift) |
{rail: "swift", recipient_name, bic_swift, bank_name, country, (iban or account_number — at least one)} |
Crypto (12 types)
type |
fields |
|---|---|
btc |
{address, (memo)} — BIP-21; currency must be BTC |
evm |
chain: "eth_mainnet"|"polygon"|"bsc"|"avalanche_c"|"arbitrum_one"|"optimism"|"base", address, (token_contract), (memo). currency is the asset code (ETH / MATIC / BNB / AVAX / USDC / USDT / DAI / WBTC / WETH / BUSD), must be supported on the picked chain |
solana |
address, (spl_token), (memo). currency ∈ {SOL, USDC, USDT, BONK, JTO, PYTH, WIF} |
tron |
address (T-prefix, 34 base58 chars), (memo). currency ∈ {TRX, USDT, USDC, USDD} |
ton |
address (48 base64 / base64url chars, EQ…/UQ… prefix), (jetton_master), (memo). currency ∈ {TON, USDT, NOT} — TON rejects jetton_master; non-canonical Jetton variants supply jetton_master |
cardano |
address (Shelley bech32 addr1..., 58–108 chars; Byron and testnet rejected), (memo). currency must be ADA. CIP-13 URI carries amount only — memo is stored for operator reference and dropped from the emitted URI |
lightning |
kind: "bolt11"|"lnurl"|"lightning_address", identifier, (memo). currency must be BTC |
xrp |
address (r-prefix), (destination_tag), (memo). currency must be XRP |
stellar |
address (G-prefix, 56 base32 chars), (memo), (issuer). currency ∈ {XLM, USDC, EURC, BRL, YBRL} — XLM rejects issuer; non-canonical issued assets supply issuer |
monero |
address, (payment_id). currency must be XMR |
utxo_alt |
chain: "LTC"|"BCH"|"DOGE", address (per-chain shape), (memo). currency matches chain |
algorand |
address (58 uppercase base32 chars, alphabet A-Z 2-7), (asset_id), (note). currency ∈ {ALGO, USDC, USDT} — ALGO rejects asset_id; non-canonical ASA variants supply asset_id (numeric uint64). URI amount is converted to micro-units (smallest unit) at build time |
P2P / fintech (15 types)
type |
fields |
|---|---|
venmo |
handle, (display_name). currency must be USD |
paypal |
identifier_type: "paypal_me"|"email", identifier, (display_name). Any ISO-4217 currency |
revolut |
revtag (5–20 alphanumeric, with or without @), (display_name). Any ISO-4217 |
wise |
identifier_type: "wisetag"|"email"|"iban", identifier, (display_name). Any ISO-4217. IBAN validates mod-97 |
cashapp |
cashtag (with or without $), (display_name). currency must be USD |
zelle |
identifier_type: "email"|"phone", identifier, (display_name). currency must be USD |
pix |
key_type: "email"|"phone"|"cpf"|"cnpj"|"random", key, (recipient_name), (recipient_city). currency must be BRL |
upi |
vpa (handle@provider), (display_name). currency must be INR |
mobile_payment |
service: "swish"|"vipps"|"mobilepay"|"twint"|"bizum"|"blik", identifier (E.164 phone for most, 6-digit code accepted for blik), (display_name). MobilePay also requires country: "DK"|"FI". currency is fixed per service (SEK / NOK / DKK or EUR / CHF / EUR / PLN) |
alipay |
identifier_type: "email"|"phone", identifier, (display_name). currency must be CNY |
wechat_pay |
identifier_type: "wechat_id"|"phone"|"email", identifier, (display_name). currency must be CNY |
interac |
identifier_type: "email"|"phone", identifier, (auto_deposit), (display_name). currency must be CAD. auto_deposit is "0" or "1" — soft UX hint, not enforced. No URI (Interac has no public deeplink) |
exchange_p2p |
service: "coinbase"|"binance"|"crypto_com"|"okx"|"bybit"|"kucoin", identifier_type: "email"|"phone"|"pay_id"|"uid"|"username" (allowed set per-service: coinbase = email; binance = pay_id / email / phone; crypto_com = username / email / phone; okx = email / phone; bybit = uid / email; kucoin = uid / email / phone), identifier, (display_name). currency is the asset the operator expects to receive (BTC / USDT / USDC / etc.; the exchange’s supported-asset list). No URI builder — manual-tier (no cross-platform deeplink) |
mercadopago |
country: "AR"|"BR"|"MX", identifier_type: "alias"|"cvu"|"cbu"|"cpf"|"clabe"|"email"|"phone" (allowed set per-country: AR = alias / cvu / cbu / email; BR = email / phone / cpf; MX = email / phone / clabe), identifier, (display_name). currency is pinned per country (AR = ARS, BR = BRL, MX = MXN). CVU / CBU strip dots and dashes before validating 22-digit length; CLABE strips before 18-digit length; CPF strips before 11-digit length. No URI builder — mpago.la short links require a server-side API call to mint |
paynow |
identifier_type: "mobile"|"nric"|"uen", identifier, (recipient_name). currency must be SGD. Mobile is E.164 +65 + 8 digits; NRIC/FIN is S/T/F/G/M + 7 digits + alpha checksum; UEN is 9 or 10 alphanumeric chars. Pay button emits a QR-only payload (kind: 'qr') rather than a URI — PayNow has no public deeplink scheme; the bank app scans the SGQR-format EMV-QR payload or accepts the same string pasted into its PayNow screen |
Other (1 type)
type |
fields |
|---|---|
custom |
{rows: [{key, value}, …]} — up to 50 rows × 64-char key × 1024-char value. Any declared currency |
Plugin-registered types declare their own field schema via
PaybackMethodTypeContract::getCatalogEntry()['fields']; the
catalog endpoint surfaces it so plugin-aware clients can build forms
without hardcoding shapes.
Example bodies for a few rails:
{
"type": "evm",
"label": "USDC on Polygon",
"currency": "USDC",
"fields": {
"address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0",
"chain": "polygon"
}
}
{
"type": "pix",
"label": "Pix (CPF)",
"currency": "BRL",
"fields": {
"key_type": "cpf",
"key": "123.456.789-09"
}
}
{
"type": "mobile_payment",
"label": "Swish",
"currency": "SEK",
"fields": {
"service": "swish",
"identifier": "+46701234567"
}
}
{
"type": "mobile_payment",
"label": "MobilePay (Denmark)",
"currency": "DKK",
"fields": {
"service": "mobilepay",
"country": "DK",
"identifier": "+4512345678"
}
}
GET /api/v1/payback-methods/:id
Fetch a single payback method with sensitive fields masked.
Permission: payback:read
Response: same row shape as the list endpoint, wrapped in {"method": {...}}.
404 Not Found if the method_id does not exist.
GET /api/v1/payback-methods/:id/reveal
Fetch a single payback method with all fields decrypted to plaintext. Use this when the caller needs to actually display or copy the IBAN / account number / Bitcoin address / etc.
Permission: payback:write — reveal exposes sensitive plaintext and is treated as a write-class operation so a read-only key cannot exfiltrate it.
Response:
{
"success": true,
"data": {
"method": {
"method_id": "pbm_01HV6...",
"type": "bank_wire",
"label": "Chase checking",
"currency": "VWL",
"priority": 100,
"share_policy": "auto",
"fields": {
"rail": "ach",
"recipient_name": "Jane Doe",
"routing_number": "021000021",
"account_number": "1234567890",
"account_type": "checking"
},
"settlement_min_unit": 1,
"settlement_min_unit_exponent": -2,
"created_at": "2026-04-01T10:30:00Z",
"updated_at": "2026-04-01T10:30:00Z"
}
}
}
PUT /api/v1/payback-methods/:id
Update an existing payback method. All fields in the body are optional — only those present are applied. To atomically re-encrypt the sensitive fields, send the complete fields object (partial field updates are not supported since the encrypted blob is rewritten wholesale).
Permission: payback:write
Request Body (all fields optional):
{
"label": "Chase – primary",
"share_policy": "auto",
"priority": 50,
"enabled": true,
"fields": { ... }
}
Only label, share_policy, priority, enabled, and fields are accepted — every other key in the body is silently ignored.
Response:
{
"success": true,
"data": {
"method_id": "pbm_01HV6..."
}
}
404 Not Found if the id does not exist. Validation errors follow the same shape as POST.
PUT /api/v1/payback-methods/:id/share-policy
Update only the share policy on an existing method. Equivalent to PUT /api/v1/payback-methods/:id with {"share_policy": "..."} but scoped so automation that only touches share policies can document intent clearly.
Permission: payback:write
Request Body:
{
"share_policy": "never"
}
Accepted values: auto, never.
Response:
{
"success": true,
"data": {
"method_id": "pbm_01HV6...",
"share_policy": "never"
}
}
DELETE /api/v1/payback-methods/:id
Permanently delete a payback method. The encrypted row is dropped; there is no soft-delete / tombstone — a fresh ID is issued if you recreate a method with the same label.
Permission: payback:write
Response:
{
"success": true,
"data": {
"method_id": "pbm_01HV6...",
"deleted": true
}
}
404 Not Found if the id does not exist.
Plugin Endpoints
Plugin management endpoints. All require admin. Toggling a plugin’s enabled flag does not restart the node — follow up with POST /api/v1/system/restart (or eiou restart on the host) for the change to take effect, since event subscriptions bind during boot.
The plugin name is validated against ^[a-z0-9][a-z0-9-_]{0,63}$ (kebab-case alphanumerics).
GET /api/v1/plugins
List every discovered plugin with full metadata (author, homepage, changelog, license, has_changelog).
Permission: admin
Response (success):
{
"success": true,
"data": {
"plugins": [
{
"name": "hello-eiou",
"version": "0.1.0",
"enabled": true,
"status": "active",
"license": "MIT",
"author": "Example Author",
"homepage": "https://example.org/hello-eiou",
"has_changelog": true
}
]
}
}
Errors:
| Code | Status | When |
|---|---|---|
plugin_loader_unavailable |
500 | Plugin system is not initialized on this node |
POST /api/v1/plugins/:name/enable
Persist enabled = true for the named plugin. Does not restart.
Permission: admin
Path parameters:
| Name | Type | Description |
|---|---|---|
name |
string | Plugin name (kebab-case alphanumerics, ≤ 64 chars) |
Response (success):
{
"success": true,
"data": {
"plugin": "hello-eiou",
"enabled": true,
"restart_required": true,
"message": "Plugin state persisted. POST /api/v1/system/restart to apply."
}
}
When the plugin declares public_routes but the node will not serve them (this plugin’s per-plugin toggle is off under the default allow ceiling, or the node-wide EIOU_PUBLIC_PLUGIN_ROUTES ceiling is explicitly off), the response data additionally carries public_routes_gated: true and a public_routes_message explaining how to turn them on. The first case is the ordinary one on a node nobody has configured, and the operator can resolve it themselves; the second needs a change to the container environment. The plugin is still enabled; only its public routes are gated.
Errors:
| Code | Status | When |
|---|---|---|
invalid_name |
400 | Plugin name failed the regex validation |
unknown_plugin |
404 | Plugin not found in the discovered set |
persist_failed |
500 | Could not write the plugin state to disk |
plugin_loader_unavailable |
500 | Plugin system is not initialized on this node |
POST /api/v1/plugins/:name/disable
Persist enabled = false for the named plugin. Does not restart.
Permission: admin
Same path parameters and response shape as enable, with enabled: false.
POST /api/v1/plugins/:name/public-routes
Turn a single plugin’s public routes (/p/<name>/<action>) on or off. This is the per-plugin toggle that applies under the node-wide EIOU_PUBLIC_PLUGIN_ROUTES=allow ceiling, which is the default; the preference is persisted regardless of the node-wide setting and applied to nginx immediately. A preference recorded while the node-wide ceiling was off does not go live when that ceiling is later raised — send this request again on a node that permits public routes. See /docs/reference/plugins (Sandboxing → Public routes) for the three-state model.
Permission: admin
Path parameters:
| Name | Type | Description |
|---|---|---|
name |
string | Plugin name (kebab-case alphanumerics, ≤ 64 chars) |
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
enabled |
boolean | Yes | true to expose this plugin’s public routes, false to keep them dark |
Response (success):
{
"success": true,
"data": {
"plugin": "hello-eiou",
"public_routes_enabled": true,
"state": "live",
"live": true
}
}
public_routes_enabled is the persisted per-plugin preference. state is the effective state: live (served under allow + toggle on), forced (served because the node forces all routes on), plugin_off (toggle off), node_off (toggle on but the node ceiling is off), or none (the plugin declares no public routes). live is true when state is live or forced. When state is forced the response also carries forced_by_node: true (the saved preference has no effect right now); when state is node_off it carries a message with guidance on raising the node ceiling.
Errors:
| Code | Status | When |
|---|---|---|
bad_request |
400 | Body did not include a boolean enabled |
invalid_name |
400 | Plugin name failed the regex validation |
unknown_plugin |
404 | Plugin not found in the discovered set |
persist_failed |
500 | Could not write the public-routes preference to disk |
plugin_loader_unavailable |
500 | Plugin system is not initialized on this node |
DELETE /api/v1/plugins/:name
Permanently uninstall a plugin. The plugin must be disabled first; an attempt to uninstall an enabled plugin returns 409 Conflict.
Runs the full step sequence: onUninstall hook → revoke MySQL grants → drop tables → drop user → delete credentials → remove files → clean state. The response carries a per-step status so partial failures (e.g. plugin files removed but a DB-side cleanup step reported an error) are surfacable.
Permission: admin
Path parameters:
| Name | Type | Description |
|---|---|---|
name |
string | Plugin name (kebab-case alphanumerics, ≤ 64 chars) |
Response (success):
{
"success": true,
"data": {
"plugin": "hello-eiou",
"uninstalled": true,
"steps": {
"on_uninstall_hook": "ok",
"revoke_grants": "ok",
"drop_tables": "ok",
"drop_user": "ok",
"delete_credentials": "ok",
"remove_files": "ok",
"clean_state": "ok"
}
}
}
Response (partial failure — success: false with 200): plugin files were removed on disk but at least one MySQL-side step reported an error. Inspect steps and resolve manually; the plugin is gone.
Errors:
| Code | Status | When |
|---|---|---|
invalid_name |
400 | Plugin name failed the regex validation |
unknown_plugin |
404 | Plugin not found |
plugin_enabled |
409 | Plugin is still enabled — disable first |
Plugin-owned endpoints
Plugins can register their own routes via PluginApiRegistry. Shape:
ANY /api/v1/plugins/:name/:action
Permission gating is set by the registering plugin (typically admin). Failures from a misbehaving plugin return a clean error response — they cannot tear down the controller.
API Key Management
These endpoints require admin permission.
GET /api/v1/keys
List all API keys.
Permission: admin
Response:
{
"success": true,
"data": {
"keys": [
{
"key_id": "eiou_abc123",
"name": "Mobile App",
"permissions": ["wallet:read", "wallet:send", "contacts:read"],
"rate_limit_per_minute": 100,
"enabled": true,
"expires_at": null,
"created_at": "2026-01-01T00:00:00Z",
"last_used_at": "2026-01-23T12:00:00Z"
}
]
}
}
POST /api/v1/keys
Create a new API key.
Permission: admin
Request Body:
{
"name": "New Integration",
"permissions": ["wallet:read", "contacts:read"],
"rate_limit_per_minute": 60,
"expires_at": "2027-01-01T00:00:00Z"
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | Yes | - | Descriptive name for the key |
permissions |
array | Yes | - | List of permissions |
rate_limit_per_minute |
int | No | 100 | Rate limit |
expires_at |
string | No | null | Expiration date (ISO 8601) |
Available Permissions:
| Permission | Description |
|---|---|
wallet:read |
Read wallet balances and transactions |
wallet:send |
Send transactions, propose/accept/reject chain drops, approve/reject P2P |
wallet:* |
Both wallet:read and wallet:send |
contacts:read |
List, view, search, and ping contacts |
contacts:write |
Add, update, delete, block/unblock contacts; per-currency operations |
contacts:* |
Both contacts:read and contacts:write |
system:read |
Read system status, metrics, and settings; download debug reports; trigger update-check |
system:write |
Trigger sync, shutdown/start/restart, change settings (operational control of this node) |
system:* |
Both system:read and system:write |
backup:read |
Read backup status/list, verify backups |
backup:write |
Create, restore, delete, enable/disable backups, cleanup |
backup:* |
Both backup:read and backup:write |
payback:read |
List/read your own payback methods (sensitive fields redacted) |
payback:write |
Create/edit/delete methods, AND reveal plaintext via GET /payback-methods/:id/reveal (write-class because it returns secrets) |
payback:* |
Both payback:read and payback:write |
admin |
Full administrative access (settings, sync, shutdown/start/restart, key management, plugin management). Implies every other scope. |
Wildcard semantics:
<category>:*grants any<category>:<verb>request —wallet:*covers anything that requireswallet:readorwallet:send. There is currently noplugin:*scope; key management (/keys/*) and plugin management (/plugins/*) requireadmin. Operational control (/system/sync,shutdown,start,restart,PUT /system/settings) was carved out fromadminintosystem:writeso a CI/automation key can poke the node without also unlocking key minting and plugin install.
Response (201 Created):
{
"success": true,
"data": {
"key_id": "eiou_xyz789",
"secret": "sk_live_abc123...",
"name": "New Integration",
"permissions": ["wallet:read", "contacts:read"],
"rate_limit_per_minute": 100,
"warning": "Save this secret now! It will not be shown again."
}
}
Fields:
key_id: Unique identifier for the API keysecret: The API secret (only shown once at creation)name: Human-readable name for the keypermissions: Array of granted permissionsrate_limit_per_minute: Maximum API calls per minute for this keywarning: Security reminder to save the secret immediately
Important: The
secretis only returned once at creation time. Store it securely.
DELETE /api/v1/keys/:key_id
Delete an API key.
Permission: admin
Response:
{
"success": true,
"data": {
"message": "API key deleted successfully",
"key_id": "eiou_xyz789"
}
}
POST /api/v1/keys/enable/:key_id
Enable a disabled API key.
Permission: admin
Response:
{
"success": true,
"data": {
"message": "API key enabled successfully",
"key_id": "eiou_xyz789"
}
}
Error Response (404):
{
"success": false,
"error": {
"code": "key_not_found",
"message": "API key not found"
}
}
POST /api/v1/keys/disable/:key_id
Disable an API key without deleting it. Disabled keys return auth_key_disabled on authentication attempts.
Permission: admin
Response:
{
"success": true,
"data": {
"message": "API key disabled successfully",
"key_id": "eiou_xyz789"
}
}
Error Response (404):
{
"success": false,
"error": {
"code": "key_not_found",
"message": "API key not found"
}
}
Status Values Reference
Contact Status
| Value | Description |
|---|---|
pending |
Contact request awaiting acceptance |
accepted |
Contact is active and can transact |
blocked |
Contact is blocked |
Contact Online Status
| Value | Description |
|---|---|
online |
Contact responded to ping |
offline |
Contact did not respond to ping |
unknown |
Ping not performed (default or feature disabled) |
Transaction Status
| Value | Description |
|---|---|
pending |
Transaction has been created |
sending |
Transaction claimed for processing |
sent |
Transaction has been sent onwards |
accepted |
Transaction accepted by peer |
rejected |
Transaction rejected by peer |
cancelled |
Transaction not received by peer in time |
completed |
Transaction accepted by final recipient |
failed |
Transaction failed after max recovery attempts |
Transaction Type
| Value | Description |
|---|---|
sent |
Outgoing transaction |
received |
Incoming transaction |
relay |
Relayed through this node |
Transaction TX Type
| Value | Description |
|---|---|
standard |
Direct transaction to known contact |
p2p |
P2P transaction to unknown contact (or part of P2P chain) |
contact |
Contact request transaction (amount=0, establishes contact) |
See Also
- API Quick Reference - Condensed API reference
- Error Codes - Complete error code reference
- CLI Reference - Command-line interface documentation
- Docker Configuration - Container configuration