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 the amount field 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

  1. Authentication
  2. Response Format
  3. Error Codes
  4. Wallet Endpoints
  5. Contact Endpoints
  6. Payment Request Endpoints
  7. System Endpoints
  8. Tx Drop Endpoints
  9. Backup Endpoints
  10. Export Endpoints
  11. Payback Methods Endpoints
  12. 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 header
  • NONCE: Same unique nonce as the header
  • BODY: 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 when REMOTE_ADDR is in the trusted proxies list. Configure via CLI: eiou changesettings trustedProxies "10.0.0.1,172.16.0.1" (see CLI Reference — changesettings). The TRUSTED_PROXIES environment 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 currency
  • available_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 with currency, fee_percent, credit_limit, status (accepted/pending), direction (incoming/outgoing), and the two minimum-credit fields below.
  • requested_minimum: On an outgoing row where you sent this contact a request that required a minimum credit limit, the minimum you required (major units). null on incoming rows and when you required no minimum.
  • requested_min_unmet: true when this contact’s most recent ping/pong shows they extend you less credit than your requested_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). null if not yet known. Stored per-currency in the contact_credit table.

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). null if not yet received. Stored per-currency in contact_credit. Per-currency fee/credit details are available via GET /api/v1/contacts (with currencies array) or GET /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). null if not yet known. Stored per-currency in contact_credit.
  • currencies: Array of per-currency configurations. Each entry has currency, 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 an outgoing row where you required a minimum credit limit, requested_minimum is that floor (major units, else null) and requested_min_unmet is true when 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 running
  • database: "healthy" or "unhealthy" based on database connectivity
  • processors: Boolean flags indicating if processor PID files exist
  • update.available: Whether a newer version exists on Docker Hub
  • update.current_version: The running node’s version
  • update.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 enabled
  • analytics.consent_pending: Whether the user has not yet been asked for analytics consent
  • analytics.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 of analytics_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 contacts
  • p2p.queued: Number of P2P relay messages waiting to be processed
  • uptime: 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 node
  • default_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 contacts
  • maximum_fee_percent: Maximum allowed fee percentage
  • default_credit_limit: Default credit limit for new contacts
  • max_p2p_level: Maximum P2P relay depth level
  • p2p_expiration_seconds: Time in seconds before P2P requests expire
  • direct_tx_expiration: Direct (non-P2P) transaction delivery timeout in seconds (0 = no expiry)
  • max_output_lines: Maximum output lines for CLI commands
  • default_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 enabled
  • auto_accept_transaction: Whether to auto-accept P2P transactions when route found
  • hop_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 enabled
  • contact_status_sync_on_ping: Whether to sync contact status during ping
  • auto_chain_drop_propose: Whether to auto-propose tx-drop operations
  • auto_chain_drop_accept: Whether to auto-accept tx-drop proposals
  • auto_chain_drop_accept_guard: Whether to run balance guard before auto-accepting
  • auto_accept_restored_contact: Whether to auto-accept restored contacts on wallet restore when transaction history proves prior relationship
  • api_enabled: Whether the REST API endpoint is enabled
  • api_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 live payment_requests table before moving to payment_requests_archive (min 1). Archived rows stay queryable — this is a move, not a delete
  • payment_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 live transactions table before moving to transactions_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 delete
  • transactions_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 fails
  • tor_fallback_require_encrypted: Only fall back to HTTPS, never plain HTTP
  • display_date_format: PHP date format string for timestamps
  • allowed_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 hostname automatically derives hostname_secure and 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 502 if both sources are unreachable
  • The result is cached — subsequent GET /api/v1/system/status calls 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 SIGTERM
  • pid_files_cleaned: Number of PID files removed

Notes:

  • Creates a shutdown flag at /tmp/eiou_shutdown.flag to 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 debug and the GUI Debug Report (all use DebugReportService)
  • 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 contact filter, returns all incoming pending proposals
  • With contact filter, 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 proposal
  • missing_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:

  • candidates contains best-fee mode route options (ordered by fee, lowest first)
  • rp2p contains the single RP2P response for fast mode (null if none)
  • Returns 404 if hash not found or not in awaiting_approval status

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 code candidate_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 enabled
  • backup_count: Number of existing backup files
  • retention_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: true if backup can be decrypted and contains valid SQL
  • version: Backup format version
  • created_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 requires wallet:read or wallet:send. There is currently no plugin:* scope; key management (/keys/*) and plugin management (/plugins/*) require admin. Operational control (/system/sync, shutdown, start, restart, PUT /system/settings) was carved out from admin into system:write so 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 key
  • secret: The API secret (only shown once at creation)
  • name: Human-readable name for the key
  • permissions: Array of granted permissions
  • rate_limit_per_minute: Maximum API calls per minute for this key
  • warning: Security reminder to save the secret immediately

Important: The secret is 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