Automated Coinos Withdrawals to Your Lightning Node
I've been using Coinos as a Lightning wallet for receiving small payments, but I wanted to automatically sweep the balance to my own Lightning node instead of keeping sats on a custodial service.
What This Script Does
This bash script automatically:
1. Checks your Coinos balance via their API
2. Creates a Lightning invoice on your node for the available amount
3. Pays that invoice through Coinos, effectively withdrawing to your node
4. Keeps a configurable reserve amount in your Coinos account
Prerequisites
- A running LND Lightning node accessible via Docker
- Coinos account with API access
curl, jq, and docker installed on your system
Setup Instructions
Get your Coinos API token:
Configure the script:
- Replace
TOKEN="secret" with your actual API token
- Adjust
RESERVE_AMOUNT (default: 2000 sats) - amount to keep in Coinos
- Adjust
MIN_WITHDRAWAL (default: 100 sats) - minimum amount to withdraw
- Change
LND_CONTAINER if your Docker container has a different name
Make executable and test:
chmod +x coinos_payout.sh
./coinos_payout.sh
Automate with cron (optional):
# Run every hour
0 * * * * /path/to/coinos_payout.sh
Configuration Options
All the important settings are at the top of the script:
TOKEN: Your Coinos API token
RESERVE_AMOUNT: Sats to keep in Coinos (default: 2000)
MIN_WITHDRAWAL: Minimum withdrawal threshold (default: 100 sats)
LND_CONTAINER: Docker container name for your LND node
LOG_FILE: Where to store logs
Why Use This?
- Self-custody: Automatically move sats from custodial Coinos to your node
- Dollar-cost averaging: Small regular withdrawals instead of manual large ones
- Automation: Set it and forget it with cron
- Reserve buffer: Keeps some sats in Coinos for immediate spending
The script includes error handling and logging, so you can monitor what's happening and troubleshoot if needed.
Security Notes
- Keep your API token secure and never commit it to version control
- Review the code before running to understand what it does
- The script has input validation and other security measures. But might not be bulletproof.
Notes
Coinos does have an autowithdrawal feature to a Bitcoin or LN address. But after https://stacker.news/items/1001011 I'd rather have my custom script.
This assumes you're running LND in Docker. If you have a different setup, you'll need to modify the lncli command accordingly.
#!/bin/bash
# =============================================================================
# Coinos Auto-Payout Script (Security Hardened)
# =============================================================================
# This script automatically withdraws your Coinos balance via Lightning Network
# by creating an invoice on your Lightning node and paying it through Coinos API
#
# Requirements:
# - curl, jq, docker installed
# - Running LND node accessible via docker
# - Coinos API token with payment permissions
# =============================================================================
# Exit on any error, undefined variable, or pipe failure
set -euo pipefail
# =============================================================================
# CONFIGURATION - MODIFY THESE VALUES
# =============================================================================
# Your Coinos API token (get from https://coinos.io/docs)
# IMPORTANT: Replace "secret" with your actual token
TOKEN="secret"
# Coinos API base URL (usually no need to change)
API_BASE="https://coinos.io/api"
# Amount to keep as reserve in your Coinos account (in sats)
# This prevents withdrawing everything and accounts for potential fees
RESERVE_AMOUNT=2000
# Minimum withdrawal amount (in sats)
# Won't create withdrawal if available amount is below this threshold
MIN_WITHDRAWAL=100
# Log file location (in a secure directory)
LOG_FILE="$HOME/.coinos_payout.log"
# Docker container name for your LND node
# Change this if your LND container has a different name
LND_CONTAINER="lnd"
# Invoice memo/description
INVOICE_MEMO="coinos withdrawal"
# API timeout in seconds
API_TIMEOUT=30
# =============================================================================
# SCRIPT LOGIC - DO NOT MODIFY BELOW THIS LINE
# =============================================================================
# Function to log with timestamp
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}
# Function to handle errors
error_exit() {
log "ERROR: $1"
exit 1
}
# Function to validate numeric input
validate_number() {
local value="$1"
local name="$2"
if ! [[ "$value" =~ ^[0-9]+$ ]]; then
error_exit "Invalid $name format: must be a positive integer"
fi
}
# Function to validate container name
validate_container_name() {
local container="$1"
if ! [[ "$container" =~ ^[a-zA-Z0-9_-]+$ ]]; then
error_exit "Invalid container name: must contain only alphanumeric characters, underscores, and hyphens"
fi
}
# Function to validate Lightning invoice format
validate_invoice() {
local invoice="$1"
# Basic Lightning invoice validation (should start with ln)
if ! [[ "$invoice" =~ ^ln[a-zA-Z0-9]+$ ]]; then
error_exit "Invalid Lightning invoice format"
fi
# Check reasonable length (Lightning invoices are typically 200-400 characters)
local len=${#invoice}
if [ "$len" -lt 100 ] || [ "$len" -gt 1000 ]; then
error_exit "Lightning invoice has unusual length: $len characters"
fi
}
# Function to validate API token
validate_token() {
local token="$1"
if [ "$token" = "secret" ]; then
error_exit "Please set your actual API token in the TOKEN variable"
fi
if [ ${#token} -lt 10 ]; then
error_exit "API token appears to be too short"
fi
}
# Function to make secure API call
make_api_call() {
local endpoint="$1"
local method="${2:-GET}"
local data="${3:-}"
local curl_opts=(
-s
-f # Fail on HTTP errors
--max-time "$API_TIMEOUT"
--connect-timeout 10
-H "content-type: application/json"
-H "Authorization: Bearer $TOKEN"
)
if [ "$method" = "POST" ] && [ -n "$data" ]; then
curl_opts+=(-d "$data")
fi
local response
if ! response=$(curl "${curl_opts[@]}" "$endpoint" 2>/dev/null); then
error_exit "API call failed: $endpoint"
fi
echo "$response"
}
# Function to safely execute docker command
execute_docker_command() {
local container="$1"
local amount="$2"
local memo="$3"
# Validate inputs
validate_container_name "$container"
validate_number "$amount" "invoice amount"
# Sanitize memo (remove potentially dangerous characters)
local safe_memo
safe_memo=$(echo "$memo" | tr -cd '[:alnum:][:space:].-_')
# Execute with proper quoting
local response
if ! response=$(docker exec "$container" lncli addinvoice --amt "$amount" --memo "$safe_memo" 2>/dev/null); then
error_exit "Failed to create Lightning invoice (check if LND container '$container' is running)"
fi
echo "$response"
}
# Initialize secure log file
init_log_file() {
# Create log file with restricted permissions
touch "$LOG_FILE"
chmod 600 "$LOG_FILE"
}
# =============================================================================
# MAIN EXECUTION
# =============================================================================
# Check if required tools are available
command -v curl >/dev/null 2>&1 || error_exit "curl is required but not installed"
command -v jq >/dev/null 2>&1 || error_exit "jq is required but not installed"
command -v docker >/dev/null 2>&1 || error_exit "docker is required but not installed"
# Initialize secure log file
init_log_file
# Validate configuration
validate_token "$TOKEN"
validate_container_name "$LND_CONTAINER"
validate_number "$RESERVE_AMOUNT" "reserve amount"
validate_number "$MIN_WITHDRAWAL" "minimum withdrawal"
log "Starting Coinos payout process..."
# Get current balance
log "Getting balance..."
BALANCE_RESPONSE=$(make_api_call "$API_BASE/me")
# Validate and extract balance
if ! echo "$BALANCE_RESPONSE" | jq -e '.balance' >/dev/null 2>&1; then
error_exit "Invalid API response format - missing balance field"
fi
BALANCE=$(echo "$BALANCE_RESPONSE" | jq -r '.balance')
validate_number "$BALANCE" "balance"
log "Current balance: $BALANCE sats"
# Calculate amount to withdraw (balance minus reserve)
AMOUNT=$((BALANCE - RESERVE_AMOUNT))
log "Amount to withdraw: $AMOUNT sats (keeping $RESERVE_AMOUNT sats as reserve)"
# Check if there's enough to withdraw
if [ "$AMOUNT" -le "$MIN_WITHDRAWAL" ]; then
log "No funds available to withdraw (available: $AMOUNT sats, minimum: $MIN_WITHDRAWAL sats)"
exit 0
fi
# Validate withdrawal amount
if [ "$AMOUNT" -le 0 ]; then
error_exit "Invalid withdrawal amount: $AMOUNT sats"
fi
# Create Lightning invoice
log "Creating Lightning invoice for $AMOUNT sats..."
INVOICE_RESPONSE=$(execute_docker_command "$LND_CONTAINER" "$AMOUNT" "$INVOICE_MEMO")
log "Raw invoice response received"
# Validate and extract payment request
if ! echo "$INVOICE_RESPONSE" | jq -e '.payment_request' >/dev/null 2>&1; then
error_exit "Invalid invoice response format - missing payment_request field"
fi
INVOICE=$(echo "$INVOICE_RESPONSE" | jq -r '.payment_request' | tr -d '\r\n"' | sed 's/[[:space:]]//g')
if [ "$INVOICE" = "null" ] || [ -z "$INVOICE" ]; then
error_exit "Could not extract payment request from invoice response"
fi
# Validate invoice format
validate_invoice "$INVOICE"
log "Invoice created: ${INVOICE:0:50}..."
# Prepare payment data
PAYMENT_DATA=$(jq -n --arg payreq "$INVOICE" '{payreq: $payreq}')
# Pay the invoice through Coinos
log "Submitting payment request to Coinos..."
PAYMENT_RESPONSE=$(make_api_call "$API_BASE/payments" "POST" "$PAYMENT_DATA")
# Validate payment response
if ! echo "$PAYMENT_RESPONSE" | jq -e '.' >/dev/null 2>&1; then
error_exit "Invalid payment response format"
fi
# Check if payment was successful (this depends on the API structure)
if echo "$PAYMENT_RESPONSE" | jq -e '.error' >/dev/null 2>&1; then
ERROR_MSG=$(echo "$PAYMENT_RESPONSE" | jq -r '.error')
error_exit "Payment failed: $ERROR_MSG"
fi
log "Payment response: $PAYMENT_RESPONSE"
log "Payout process completed successfully"
https://stacker.news/items/1034295
Published at
2025-07-11 08:47:34 UTCEvent JSON
{
"id": "5de7eec8cf0e0f9d7f3b7c046dd3090f5e7cba1e855529562060c8e729473cd0",
"pubkey": "7459d333af66066f066cf87796e690db3a96ff4534f9edf4eab74df2f207289b",
"created_at": 1752223654,
"kind": 30023,
"tags": [
[
"d",
"1034295"
],
[
"title",
"Automated Coinos Withdrawals to Your Lightning Node"
],
[
"published_at",
"1752223654"
]
],
"content": "I've been using [Coinos](https://coinos.io) as a Lightning wallet for receiving small payments, but I wanted to automatically sweep the balance to my own Lightning node instead of keeping sats on a custodial service.\n\n## What This Script Does\n\nThis bash script automatically:\n1. Checks your Coinos balance via their API\n2. Creates a Lightning invoice on your node for the available amount\n3. Pays that invoice through Coinos, effectively withdrawing to your node\n4. Keeps a configurable reserve amount in your Coinos account\n\n## Prerequisites\n\n- A running LND Lightning node accessible via Docker\n- Coinos account with API access\n- `curl`, `jq`, and `docker` installed on your system\n\n## Setup Instructions\n\n1. **Get your Coinos API token:**\n - Go to https://coinos.io/docs\n - Copy the token\n\n2. **Configure the script:**\n - Replace `TOKEN=\"secret\"` with your actual API token\n - Adjust `RESERVE_AMOUNT` (default: 2000 sats) - amount to keep in Coinos\n - Adjust `MIN_WITHDRAWAL` (default: 100 sats) - minimum amount to withdraw\n - Change `LND_CONTAINER` if your Docker container has a different name\n\n3. **Make executable and test:**\n ```bash\n chmod +x coinos_payout.sh\n ./coinos_payout.sh\n ```\n\n4. **Automate with cron (optional):**\n ```bash\n # Run every hour\n 0 * * * * /path/to/coinos_payout.sh\n ```\n\n## Configuration Options\n\nAll the important settings are at the top of the script:\n\n- `TOKEN`: Your Coinos API token\n- `RESERVE_AMOUNT`: Sats to keep in Coinos (default: 2000)\n- `MIN_WITHDRAWAL`: Minimum withdrawal threshold (default: 100 sats)\n- `LND_CONTAINER`: Docker container name for your LND node\n- `LOG_FILE`: Where to store logs\n\n## Why Use This?\n\n- **Self-custody**: Automatically move sats from custodial Coinos to your node\n- **Dollar-cost averaging**: Small regular withdrawals instead of manual large ones\n- **Automation**: Set it and forget it with cron\n- **Reserve buffer**: Keeps some sats in Coinos for immediate spending\n\nThe script includes error handling and logging, so you can monitor what's happening and troubleshoot if needed.\n\n## Security Notes\n\n- Keep your API token secure and never commit it to version control\n- Review the code before running to understand what it does\n- The script has input validation and other security measures. But might not be bulletproof.\n\n## Notes\n\nCoinos does have an autowithdrawal feature to a Bitcoin or LN address. But after https://stacker.news/items/1001011 I'd rather have my custom script.\n\n---\n\n*This assumes you're running LND in Docker. If you have a different setup, you'll need to modify the `lncli` command accordingly.*\n\n\n---\n\n```bash\n#!/bin/bash\n\n# =============================================================================\n# Coinos Auto-Payout Script (Security Hardened)\n# =============================================================================\n# This script automatically withdraws your Coinos balance via Lightning Network\n# by creating an invoice on your Lightning node and paying it through Coinos API\n#\n# Requirements:\n# - curl, jq, docker installed\n# - Running LND node accessible via docker\n# - Coinos API token with payment permissions\n# =============================================================================\n\n# Exit on any error, undefined variable, or pipe failure\nset -euo pipefail\n\n# =============================================================================\n# CONFIGURATION - MODIFY THESE VALUES\n# =============================================================================\n\n# Your Coinos API token (get from https://coinos.io/docs)\n# IMPORTANT: Replace \"secret\" with your actual token\nTOKEN=\"secret\"\n\n# Coinos API base URL (usually no need to change)\nAPI_BASE=\"https://coinos.io/api\"\n\n# Amount to keep as reserve in your Coinos account (in sats)\n# This prevents withdrawing everything and accounts for potential fees\nRESERVE_AMOUNT=2000\n\n# Minimum withdrawal amount (in sats)\n# Won't create withdrawal if available amount is below this threshold\nMIN_WITHDRAWAL=100\n\n# Log file location (in a secure directory)\nLOG_FILE=\"$HOME/.coinos_payout.log\"\n\n# Docker container name for your LND node\n# Change this if your LND container has a different name\nLND_CONTAINER=\"lnd\"\n\n# Invoice memo/description\nINVOICE_MEMO=\"coinos withdrawal\"\n\n# API timeout in seconds\nAPI_TIMEOUT=30\n\n# =============================================================================\n# SCRIPT LOGIC - DO NOT MODIFY BELOW THIS LINE\n# =============================================================================\n\n# Function to log with timestamp\nlog() {\n echo \"$(date '+%Y-%m-%d %H:%M:%S') - $1\" | tee -a \"$LOG_FILE\"\n}\n\n# Function to handle errors\nerror_exit() {\n log \"ERROR: $1\"\n exit 1\n}\n\n# Function to validate numeric input\nvalidate_number() {\n local value=\"$1\"\n local name=\"$2\"\n \n if ! [[ \"$value\" =~ ^[0-9]+$ ]]; then\n error_exit \"Invalid $name format: must be a positive integer\"\n fi\n}\n\n# Function to validate container name\nvalidate_container_name() {\n local container=\"$1\"\n \n if ! [[ \"$container\" =~ ^[a-zA-Z0-9_-]+$ ]]; then\n error_exit \"Invalid container name: must contain only alphanumeric characters, underscores, and hyphens\"\n fi\n}\n\n# Function to validate Lightning invoice format\nvalidate_invoice() {\n local invoice=\"$1\"\n \n # Basic Lightning invoice validation (should start with ln)\n if ! [[ \"$invoice\" =~ ^ln[a-zA-Z0-9]+$ ]]; then\n error_exit \"Invalid Lightning invoice format\"\n fi\n \n # Check reasonable length (Lightning invoices are typically 200-400 characters)\n local len=${#invoice}\n if [ \"$len\" -lt 100 ] || [ \"$len\" -gt 1000 ]; then\n error_exit \"Lightning invoice has unusual length: $len characters\"\n fi\n}\n\n# Function to validate API token\nvalidate_token() {\n local token=\"$1\"\n \n if [ \"$token\" = \"secret\" ]; then\n error_exit \"Please set your actual API token in the TOKEN variable\"\n fi\n \n if [ ${#token} -lt 10 ]; then\n error_exit \"API token appears to be too short\"\n fi\n}\n\n# Function to make secure API call\nmake_api_call() {\n local endpoint=\"$1\"\n local method=\"${2:-GET}\"\n local data=\"${3:-}\"\n \n local curl_opts=(\n -s\n -f # Fail on HTTP errors\n --max-time \"$API_TIMEOUT\"\n --connect-timeout 10\n -H \"content-type: application/json\"\n -H \"Authorization: Bearer $TOKEN\"\n )\n \n if [ \"$method\" = \"POST\" ] \u0026\u0026 [ -n \"$data\" ]; then\n curl_opts+=(-d \"$data\")\n fi\n \n local response\n if ! response=$(curl \"${curl_opts[@]}\" \"$endpoint\" 2\u003e/dev/null); then\n error_exit \"API call failed: $endpoint\"\n fi\n \n echo \"$response\"\n}\n\n# Function to safely execute docker command\nexecute_docker_command() {\n local container=\"$1\"\n local amount=\"$2\"\n local memo=\"$3\"\n \n # Validate inputs\n validate_container_name \"$container\"\n validate_number \"$amount\" \"invoice amount\"\n \n # Sanitize memo (remove potentially dangerous characters)\n local safe_memo\n safe_memo=$(echo \"$memo\" | tr -cd '[:alnum:][:space:].-_')\n \n # Execute with proper quoting\n local response\n if ! response=$(docker exec \"$container\" lncli addinvoice --amt \"$amount\" --memo \"$safe_memo\" 2\u003e/dev/null); then\n error_exit \"Failed to create Lightning invoice (check if LND container '$container' is running)\"\n fi\n \n echo \"$response\"\n}\n\n# Initialize secure log file\ninit_log_file() {\n # Create log file with restricted permissions\n touch \"$LOG_FILE\"\n chmod 600 \"$LOG_FILE\"\n}\n\n# =============================================================================\n# MAIN EXECUTION\n# =============================================================================\n\n# Check if required tools are available\ncommand -v curl \u003e/dev/null 2\u003e\u00261 || error_exit \"curl is required but not installed\"\ncommand -v jq \u003e/dev/null 2\u003e\u00261 || error_exit \"jq is required but not installed\"\ncommand -v docker \u003e/dev/null 2\u003e\u00261 || error_exit \"docker is required but not installed\"\n\n# Initialize secure log file\ninit_log_file\n\n# Validate configuration\nvalidate_token \"$TOKEN\"\nvalidate_container_name \"$LND_CONTAINER\"\nvalidate_number \"$RESERVE_AMOUNT\" \"reserve amount\"\nvalidate_number \"$MIN_WITHDRAWAL\" \"minimum withdrawal\"\n\nlog \"Starting Coinos payout process...\"\n\n# Get current balance\nlog \"Getting balance...\"\nBALANCE_RESPONSE=$(make_api_call \"$API_BASE/me\")\n\n# Validate and extract balance\nif ! echo \"$BALANCE_RESPONSE\" | jq -e '.balance' \u003e/dev/null 2\u003e\u00261; then\n error_exit \"Invalid API response format - missing balance field\"\nfi\n\nBALANCE=$(echo \"$BALANCE_RESPONSE\" | jq -r '.balance')\nvalidate_number \"$BALANCE\" \"balance\"\n\nlog \"Current balance: $BALANCE sats\"\n\n# Calculate amount to withdraw (balance minus reserve)\nAMOUNT=$((BALANCE - RESERVE_AMOUNT))\nlog \"Amount to withdraw: $AMOUNT sats (keeping $RESERVE_AMOUNT sats as reserve)\"\n\n# Check if there's enough to withdraw\nif [ \"$AMOUNT\" -le \"$MIN_WITHDRAWAL\" ]; then\n log \"No funds available to withdraw (available: $AMOUNT sats, minimum: $MIN_WITHDRAWAL sats)\"\n exit 0\nfi\n\n# Validate withdrawal amount\nif [ \"$AMOUNT\" -le 0 ]; then\n error_exit \"Invalid withdrawal amount: $AMOUNT sats\"\nfi\n\n# Create Lightning invoice\nlog \"Creating Lightning invoice for $AMOUNT sats...\"\nINVOICE_RESPONSE=$(execute_docker_command \"$LND_CONTAINER\" \"$AMOUNT\" \"$INVOICE_MEMO\")\n\nlog \"Raw invoice response received\"\n\n# Validate and extract payment request\nif ! echo \"$INVOICE_RESPONSE\" | jq -e '.payment_request' \u003e/dev/null 2\u003e\u00261; then\n error_exit \"Invalid invoice response format - missing payment_request field\"\nfi\n\nINVOICE=$(echo \"$INVOICE_RESPONSE\" | jq -r '.payment_request' | tr -d '\\r\\n\"' | sed 's/[[:space:]]//g')\n\nif [ \"$INVOICE\" = \"null\" ] || [ -z \"$INVOICE\" ]; then\n error_exit \"Could not extract payment request from invoice response\"\nfi\n\n# Validate invoice format\nvalidate_invoice \"$INVOICE\"\n\nlog \"Invoice created: ${INVOICE:0:50}...\"\n\n# Prepare payment data\nPAYMENT_DATA=$(jq -n --arg payreq \"$INVOICE\" '{payreq: $payreq}')\n\n# Pay the invoice through Coinos\nlog \"Submitting payment request to Coinos...\"\nPAYMENT_RESPONSE=$(make_api_call \"$API_BASE/payments\" \"POST\" \"$PAYMENT_DATA\")\n\n# Validate payment response\nif ! echo \"$PAYMENT_RESPONSE\" | jq -e '.' \u003e/dev/null 2\u003e\u00261; then\n error_exit \"Invalid payment response format\"\nfi\n\n# Check if payment was successful (this depends on the API structure)\nif echo \"$PAYMENT_RESPONSE\" | jq -e '.error' \u003e/dev/null 2\u003e\u00261; then\n ERROR_MSG=$(echo \"$PAYMENT_RESPONSE\" | jq -r '.error')\n error_exit \"Payment failed: $ERROR_MSG\"\nfi\n\nlog \"Payment response: $PAYMENT_RESPONSE\"\nlog \"Payout process completed successfully\"\n```\n\nhttps://stacker.news/items/1034295",
"sig": "9e44a1d49f9ae4e7645b02629f83bd0e9db905266c1a6fec2610b8837f70ba848e992058cce6629f23487b082b460c99ee0dd391da3efd47e9afb770be9705cb"
}